Extract API, migrations and portable NAS deployment
This commit is contained in:
commit
673255fa4f
57 files changed
+3799
No files matched your search
+139
@@ -0,0 +1,139 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { Pool, PoolConnection } from 'mariadb';
|
||||
import { customerSchema, deliverySchema, totalPrice, type Change, type Delivery, type Operation, type PullResponse } from '@model/shared';
|
||||
|
||||
export class SyncError extends Error {
|
||||
constructor(public code: string, public statusCode: number, message: string, public current?: Change) { super(message); }
|
||||
}
|
||||
function json<T>(value: string | T): T { return typeof value === 'string' ? JSON.parse(value) as T : value; }
|
||||
function requestHashes(operation: Operation): string[] {
|
||||
const hashes = [createHash('sha256').update(JSON.stringify(operation)).digest('hex')];
|
||||
const addCompatibleHash = (compatible: unknown) => {
|
||||
const hash = createHash('sha256').update(JSON.stringify(compatible)).digest('hex');
|
||||
if (!hashes.includes(hash)) hashes.push(hash);
|
||||
};
|
||||
// V2 clients did not send the optional profile/structured-address defaults added in V3.
|
||||
// Accept only that exact old representation for an already-owned idempotency receipt.
|
||||
if (operation.kind === 'customer.create' && !operation.data.contactName && !operation.data.phone && !operation.data.comments && Object.values(operation.data.deliveryWindows).every(value => value === null)) {
|
||||
const legacy = { name: operation.data.name };
|
||||
addCompatibleHash({ ...operation, data: legacy });
|
||||
}
|
||||
if (operation.kind === 'address.create' && !operation.data.city && !operation.data.street && !operation.data.buildingNumber) {
|
||||
const legacy = { customerId: operation.data.customerId, label: operation.data.label, address: operation.data.address };
|
||||
addCompatibleHash({ ...operation, data: legacy });
|
||||
}
|
||||
let timingCompatible: Operation | undefined;
|
||||
if ((operation.kind === 'delivery.create' || operation.kind === 'delivery.update') && operation.data.deliverySlot == null && operation.data.specificTime == null && operation.data.deliveryWindow == null) {
|
||||
const { deliverySlot: _deliverySlot, specificTime: _specificTime, deliveryWindow: _deliveryWindow, ...legacy } = operation.data;
|
||||
timingCompatible = { ...operation, data: legacy } as Operation;
|
||||
addCompatibleHash(timingCompatible);
|
||||
}
|
||||
if (operation.kind === 'delivery.create' && operation.data.scheduledDate === null) {
|
||||
const { scheduledDate: _scheduledDate, ...legacy } = operation.data;
|
||||
addCompatibleHash({ ...operation, data: legacy });
|
||||
if (timingCompatible?.kind === 'delivery.create') {
|
||||
const { scheduledDate: _legacyScheduledDate, ...oldest } = timingCompatible.data;
|
||||
addCompatibleHash({ ...timingCompatible, data: oldest });
|
||||
}
|
||||
}
|
||||
return hashes;
|
||||
}
|
||||
|
||||
export async function appendChange(conn: PoolConnection, change: Change) {
|
||||
// Caller holds sync_state FOR UPDATE through commit. Sequence order is commit order.
|
||||
await conn.query('UPDATE sync_state SET sequence = sequence + 1 WHERE id = 1');
|
||||
await conn.query('INSERT INTO sync_changes(sequence, entity_type, entity_id, change_json) SELECT sequence, ?, ?, ? FROM sync_state WHERE id = 1', [change.entityType, change.record.id, JSON.stringify(change)]);
|
||||
}
|
||||
|
||||
export async function applyOperation(pool: Pool, operation: Operation, actorUserId: string | null = null) {
|
||||
const [requestHash, ...compatibleHashes] = requestHashes(operation);
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
const state = await conn.query('SELECT next_delivery_number FROM sync_state WHERE id = 1 FOR UPDATE');
|
||||
if (!state.length) throw new Error('Database is not migrated');
|
||||
const previous = await conn.query('SELECT request_hash, result_json, actor_user_id FROM sync_operations WHERE operation_id = ?', [operation.operationId]);
|
||||
if (previous.length) {
|
||||
if (previous[0].request_hash !== requestHash && !compatibleHashes.includes(previous[0].request_hash)) throw new SyncError('OPERATION_REUSED', 409, 'Operation ID has different content');
|
||||
if (previous[0].actor_user_id && previous[0].actor_user_id !== actorUserId) throw new SyncError('OPERATION_OWNER_CHANGED', 409, 'Operation belongs to another account');
|
||||
await conn.commit();
|
||||
return { change: json<Change>(previous[0].result_json), replayed: true };
|
||||
}
|
||||
const entityType = operation.kind.split('.')[0]!;
|
||||
const existing = await conn.query('SELECT change_json FROM sync_changes WHERE entity_type = ? AND entity_id = ? ORDER BY sequence DESC LIMIT 1', [entityType, operation.entityId]);
|
||||
if (existing.length && operation.kind.endsWith('.create')) throw new SyncError('VERSION_CONFLICT', 409, 'Entity already exists', json<Change>(existing[0].change_json));
|
||||
|
||||
const meta = { id: operation.entityId, version: 1, createdAt: new Date().toISOString(), deletedAt: null };
|
||||
let change: Change;
|
||||
if (operation.kind === 'customer.create') {
|
||||
const record = customerSchema.parse({ ...operation.data, ...meta, updatedAt: null });
|
||||
const windows = record.deliveryWindows;
|
||||
await conn.query('INSERT INTO customers(id, name, contact_name, phone, comments, morning_start, morning_end, afternoon_start, afternoon_end, late_night_start, late_night_end, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [record.id, record.name, record.contactName, record.phone, record.comments, windows.morning?.start ?? null, windows.morning?.end ?? null, windows.afternoon?.start ?? null, windows.afternoon?.end ?? null, windows.lateNight?.start ?? null, windows.lateNight?.end ?? null, record.createdAt]);
|
||||
change = { entityType: 'customer', record };
|
||||
} else if (operation.kind === 'customer.update' || operation.kind === 'customer.remove') {
|
||||
if (!existing.length) throw new SyncError('CUSTOMER_MISSING', 404, 'Customer is not available');
|
||||
const current = customerSchema.parse(json<Change>(existing[0].change_json).record);
|
||||
const currentChange: Change = { entityType: 'customer', record: current };
|
||||
if (current.version !== operation.baseVersion) throw new SyncError('VERSION_CONFLICT', 409, 'Customer changed on another device', currentChange);
|
||||
if (current.deletedAt) throw new SyncError('CUSTOMER_REMOVED', 409, 'Customer is archived', currentChange);
|
||||
const record = customerSchema.parse(operation.kind === 'customer.update'
|
||||
? { ...current, ...operation.data, version: current.version + 1, updatedAt: meta.createdAt }
|
||||
: { ...current, version: current.version + 1, updatedAt: meta.createdAt, deletedAt: meta.createdAt });
|
||||
const windows = record.deliveryWindows;
|
||||
await conn.query('UPDATE customers SET name = ?, contact_name = ?, phone = ?, comments = ?, morning_start = ?, morning_end = ?, afternoon_start = ?, afternoon_end = ?, late_night_start = ?, late_night_end = ?, version = ?, updated_at = ?, deleted_at = ? WHERE id = ? AND version = ?', [record.name, record.contactName, record.phone, record.comments, windows.morning?.start ?? null, windows.morning?.end ?? null, windows.afternoon?.start ?? null, windows.afternoon?.end ?? null, windows.lateNight?.start ?? null, windows.lateNight?.end ?? null, record.version, record.updatedAt, record.deletedAt, record.id, operation.baseVersion]);
|
||||
change = { entityType: 'customer', record };
|
||||
} else if (operation.kind === 'address.create') {
|
||||
const record = { ...operation.data, ...meta };
|
||||
const customer = await conn.query('SELECT id FROM customers WHERE id = ? AND deleted_at IS NULL', [record.customerId]);
|
||||
if (!customer.length) throw new SyncError('CUSTOMER_MISSING', 422, 'Customer is not available');
|
||||
await conn.query('INSERT INTO customer_addresses(id, customer_id, label, address, city, street, building_number, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [record.id, record.customerId, record.label, record.address, record.city, record.street, record.buildingNumber, record.createdAt]);
|
||||
change = { entityType: 'address', record };
|
||||
} else if (operation.kind === 'delivery.create') {
|
||||
const data = operation.data;
|
||||
const address = await conn.query('SELECT a.id FROM customer_addresses a JOIN customers c ON c.id = a.customer_id WHERE a.id = ? AND a.customer_id = ? AND a.deleted_at IS NULL AND c.deleted_at IS NULL', [data.addressId, data.customerId]);
|
||||
if (!address.length) throw new SyncError('ADDRESS_MISMATCH', 422, 'Select an active address assigned to this customer');
|
||||
const types = await conn.query('SELECT version, shipment_unit_hundredths FROM delivery_types WHERE id = ? AND deleted_at IS NULL', [data.deliveryTypeId]);
|
||||
if (!types.length || types[0].version !== data.deliveryTypeVersion || types[0].shipment_unit_hundredths !== data.shipmentUnitHundredths) {
|
||||
throw new SyncError('CATALOG_CONFLICT', 409, 'Delivery type changed; the saved draft needs review');
|
||||
}
|
||||
const record = deliverySchema.parse({ ...data, ...meta, deliveryNumber: state[0].next_delivery_number as number, totalPriceAgorot: totalPrice(data.unitPriceAgorot, data.shipmentUnitHundredths), status: 'created' });
|
||||
await conn.query('UPDATE sync_state SET next_delivery_number = next_delivery_number + 1 WHERE id = 1');
|
||||
await conn.query('INSERT INTO deliveries(id, delivery_number, customer_id, address_id, scheduled_date, delivery_slot, specific_time, delivery_window_start, delivery_window_end, delivery_type_id, delivery_type_version, shipment_unit_hundredths, unit_price_agorot, total_price_agorot, notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [record.id, record.deliveryNumber, record.customerId, record.addressId, record.scheduledDate, record.deliverySlot, record.specificTime, record.deliveryWindow?.start ?? null, record.deliveryWindow?.end ?? null, record.deliveryTypeId, record.deliveryTypeVersion, record.shipmentUnitHundredths, record.unitPriceAgorot, record.totalPriceAgorot, record.notes, record.createdAt]);
|
||||
change = { entityType: 'delivery', record };
|
||||
} else {
|
||||
if (!existing.length) throw new SyncError('DELIVERY_MISSING', 404, 'Delivery is not available');
|
||||
const saved = json<Change>(existing[0].change_json);
|
||||
const current = deliverySchema.parse(saved.record);
|
||||
const currentChange: Change = { entityType: 'delivery', record: current };
|
||||
if (current.version !== operation.baseVersion) throw new SyncError('VERSION_CONFLICT', 409, 'Delivery changed on another device', currentChange);
|
||||
if (current.status === 'cancelled' || current.deletedAt) throw new SyncError('DELIVERY_CANCELLED', 409, 'Cancelled deliveries cannot be changed', currentChange);
|
||||
let record: Delivery = { ...current, version: current.version + 1, updatedAt: meta.createdAt };
|
||||
if (operation.kind === 'delivery.update') {
|
||||
const data = operation.data;
|
||||
const address = await conn.query('SELECT a.id FROM customer_addresses a JOIN customers c ON c.id = a.customer_id WHERE a.id = ? AND a.customer_id = ? AND a.deleted_at IS NULL AND c.deleted_at IS NULL', [data.addressId, data.customerId]);
|
||||
if (!address.length) throw new SyncError('ADDRESS_MISMATCH', 422, 'Select an active address assigned to this customer');
|
||||
record = deliverySchema.parse({ ...record, ...data, scheduledDate: data.scheduledDate ?? current.scheduledDate });
|
||||
} else record = { ...record, status: 'cancelled', cancelledAt: meta.createdAt, cancellationReason: operation.data.reason };
|
||||
await conn.query('UPDATE deliveries SET customer_id = ?, address_id = ?, scheduled_date = ?, delivery_slot = ?, specific_time = ?, delivery_window_start = ?, delivery_window_end = ?, notes = ?, status = ?, version = ?, updated_at = ?, cancelled_at = ?, cancellation_reason = ? WHERE id = ? AND version = ?', [record.customerId, record.addressId, record.scheduledDate, record.deliverySlot, record.specificTime, record.deliveryWindow?.start ?? null, record.deliveryWindow?.end ?? null, record.notes, record.status, record.version, record.updatedAt, record.cancelledAt, record.cancellationReason, record.id, operation.baseVersion]);
|
||||
change = { entityType: 'delivery', record };
|
||||
}
|
||||
await appendChange(conn, change);
|
||||
await conn.query('INSERT INTO sync_operations(operation_id, device_id, request_hash, result_json, created_at, actor_user_id) VALUES (?, ?, ?, ?, ?, ?)', [operation.operationId, operation.deviceId, requestHash, JSON.stringify(change), meta.createdAt, actorUserId]);
|
||||
await conn.commit();
|
||||
return { change, replayed: false };
|
||||
} catch (error) {
|
||||
await conn.rollback();
|
||||
throw error;
|
||||
} finally { conn.release(); }
|
||||
}
|
||||
|
||||
export async function pullChanges(pool: Pool, cursor: string, limit = 100): Promise<PullResponse> {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const state = await conn.query('SELECT sequence FROM sync_state WHERE id = 1');
|
||||
if (BigInt(cursor) > BigInt(state[0].sequence)) throw new SyncError('CURSOR_AHEAD', 409, 'Server history is older than this device; restore or reconcile before syncing');
|
||||
const rows = await conn.query('SELECT sequence, change_json FROM sync_changes WHERE sequence > ? ORDER BY sequence LIMIT ?', [cursor, limit + 1]);
|
||||
const changes: PullResponse['changes'] = rows.slice(0, limit).map((row: { sequence: bigint; change_json: string }) => ({ sequence: String(row.sequence), change: json<Change>(row.change_json) }));
|
||||
return { changes, nextCursor: changes.at(-1)?.sequence ?? cursor, hasMore: rows.length > limit };
|
||||
} finally { conn.release(); }
|
||||
}
|
||||
Reference in new issue
Block a user