From 99e7046d48adbd7a277360de005c13703ad81514 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 23 Jul 2026 07:09:11 +0900 Subject: [PATCH] fix(offline): adopt server identities safely --- projects/kit/README.md | 90 +++++++-- .../src/lib/offline-replica-schema.spec.ts | 40 ++++ .../offline/src/lib/offline-replica-schema.ts | 21 +- .../src/lib/offline-repository.spec.ts | 153 +++++++++++++++ .../kit/offline/src/lib/offline-repository.ts | 25 ++- .../src/lib/offline-sync.service.spec.ts | 181 +++++++++++++++++- .../offline/src/lib/offline-sync.service.ts | 19 +- 7 files changed, 508 insertions(+), 21 deletions(-) diff --git a/projects/kit/README.md b/projects/kit/README.md index 41268af..c7c9f62 100644 --- a/projects/kit/README.md +++ b/projects/kit/README.md @@ -346,22 +346,48 @@ requests only, a transport failure with `status=0` may return a local replica re `X-Offline-Response: local`. `POST` and other write methods always go to transport unchanged; outbox replay requests bypass policy with `OFFLINE_BYPASS` while still using the same transport observation. -Native applications install the Insiders package in the application, then pass its `Sqlite` export -and an encryption key loaded from secure device storage to the kit: +The native offline runtime currently requires Capacitor 8, `@capawesome-team/capacitor-sqlite` 0.3.x, and +`@capawesome-team/capacitor-secure-preferences` 0.2.x. Configure the private Insiders registry with the license key +before installing the SQLite and Secure Preferences packages plus the SQLite WASM runtime. Supply the license key +through a local/CI secret; never commit it to `.npmrc`. ```bash -npm install @capawesome-team/capacitor-sqlite +npm config set @capawesome-team:registry https://npm.registry.capawesome.io +npm config set //npm.registry.capawesome.io/:_authToken "$CAPAWESOME_LICENSE_KEY" +npm install @capawesome-team/capacitor-sqlite@^0.3.0 \ + @capawesome-team/capacitor-secure-preferences@^0.2.0 \ + @sqlite.org/sqlite-wasm +npx cap sync ``` +Applications on an older Capacitor major must not install those versions; upgrade to Capacitor 8 before enabling +the standard native offline runtime. After installation, follow both plugins' platform steps. In particular, exclude +`CAPAWESOME_SECURE_PREFERENCES.xml` from Android 11-and-lower `fullBackupContent` and Android 12+ cloud backup rules, +so the database key is not restored independently of its device keystore material. + +Pass the `Sqlite` export and a database key loaded from secure device storage to the kit. Never hard-code or derive +the database key from a user identifier or access token. + ```ts +import { SecurePreferences } from '@capawesome-team/capacitor-secure-preferences'; +import { Sqlite } from '@capawesome-team/capacitor-sqlite'; + +const OFFLINE_DATABASE_KEY = 'product-offline-database-key'; + +async function offlineDatabaseKey(): Promise { + const { value } = await SecurePreferences.get({ key: OFFLINE_DATABASE_KEY }); + if (value) return value; + + const bytes = crypto.getRandomValues(new Uint8Array(32)); + const generated = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); + await SecurePreferences.set({ key: OFFLINE_DATABASE_KEY, value: generated }); + return generated; +} + provideOffline({ // ...product policies, puller, and executor sqlitePlugin: Sqlite, - encryptionKey: async () => { - const { value } = await securePreferences.get({ key: 'offline-database-key' }); - if (!value) throw new Error('offline-database-key is missing'); - return value; - }, + encryptionKey: offlineDatabaseKey, }); ``` @@ -371,6 +397,42 @@ Immediately before each send, the executor receives the latest `{ localId, serve SQLite; a successful create adds `serverId` without replacing `localId`. Entity projection and outbox append/removal are committed in one local transaction. +| Identity | SQLite column | Before synchronization | After server acknowledgement | +| --- | --- | --- | --- | +| `localId` | `local_id` | client-generated UUID | unchanged UUID | +| `serverId` | `server_id` | `NULL` for a new entity | positive server `AUTO_INCREMENT` id | + +The write lifecycle is: update the replica immediately → append an outbox command in the same transaction → render +the optimistic value → replay in the background → validate the server revision → store the confirmed value and +revision. The server remains authoritative; SQLite is the durable local working database, not an HTTP response cache. + +`serverId()` supports positive safe integers only. Products must expose an internal numeric primary key for a +replicated entity; a human-facing string such as a public code, slip number, or SKU remains an ordinary replicated +column. There is intentionally no text-server-id overload. + +When the application already knows the numeric server id but the first replica pull has not materialized the row, +pass that identity explicitly while adopting the entity. This is required for updates and especially deletes: an +omitted id would otherwise make the executor interpret the row as a not-yet-created local entity. + +```ts +await offlineSync.enqueue({ + groupId, + aggregateType: 'items', + aggregateLocalId: localId, + serverId: existingApiItem.id, + operation: 'items.delete', + payload: { method: 'DELETE' }, + optimisticValue: existingApiItem, +}); +``` + +The mapping is immutable and unique inside its effective replica scope. Reassigning one `localId` to another +`serverId`, or assigning the same `serverId` to another `localId`, rejects before persistence. Web storage enforces +the same rule transactionally as SQLite's unique indexes: group-scoped entities are unique per user/group/source, +and user-scoped entities are unique per user/source across groups. If an adopted row has no confirmed baseline and +its final command is discarded, the local row is removed; the next pull may materialize the authoritative server +row again. A row with a confirmed baseline rolls back to that baseline instead. + Each synchronization cycle pulls authoritative server deltas before replaying the outbox. Every page carries the replica schema version/hash and advances a durable user/group cursor in the same transaction as its rows. A schema mismatch, malformed row, or non-advancing cursor rejects synchronization without advancing that cursor. If a remote @@ -440,9 +502,13 @@ provideOffline({ }); ``` -The schema definition must map every `ItemSelect` key exactly once as a SQLite column, `serverId()`, or -`ignored(reason)`, with exactly one `serverId()` per replicated entity. Nullable Hono columns require -`nullable(...)`; non-null columns reject it. Therefore adding, +Offline replica schema consumers must compile with TypeScript `strictNullChecks: true`. This is part of the standard, +not a compatibility option: without strict null checking, TypeScript cannot distinguish a nullable Hono property +from a required one and the schema lock cannot prove the SQLite mapping. + +The schema definition must import the Hono package's exported `$inferSelect` type and map every key exactly once as +a SQLite column, `serverId()`, or `ignored(reason)`, with exactly one numeric `serverId()` per replicated entity. +Nullable Hono columns require `nullable(...)`; non-null columns reject it. Therefore adding, removing, or changing nullability of a Drizzle column breaks the app build until its replica mapping is updated. At runtime, `values` contains only the mapped column projection; `localId` and `serverId` remain dedicated replica fields and ignored server fields are never persisted. @@ -452,6 +518,8 @@ Encrypted native builds also require the plugin's SQLCipher platform setup: enab CocoaPods, or enable the `SQLCipher` package trait when using Swift Package Manager on iOS. Follow the [Capawesome SQLite installation guide](https://capawesome.io/docs/plugins/sqlite/#installation) for the exact native configuration and export-compliance notes. +Also follow the [Capawesome Secure Preferences installation guide](https://capawesome.io/docs/plugins/secure-preferences/#installation), +including its Android backup exclusion rules. - **Status classification**: `0`→`onNetworkError` (connected only), `429`→`onRateLimited`, `502/503/504`→`onServerBusy`, `400/422/500`+message→`onServerError`, `401`→`onUnauthorized`, `403`→`onForbidden`. Other statuses (e.g. `404`) are left to the caller. - **Universal 60s timeout** — every request fails with a synthetic (retryable) `408` if it hangs for 60s. Deliberately generous (catches a dead server without cutting off a large upload / AI generation; `timeout({ each })` resets per emission, so streaming is unaffected). Not configurable — one fleet-wide behavior. diff --git a/projects/kit/offline/src/lib/offline-replica-schema.spec.ts b/projects/kit/offline/src/lib/offline-replica-schema.spec.ts index 30748ab..22064b3 100644 --- a/projects/kit/offline/src/lib/offline-replica-schema.spec.ts +++ b/projects/kit/offline/src/lib/offline-replica-schema.spec.ts @@ -741,4 +741,44 @@ describe('offline-replica-schema types', () => { }, }); }); + + it('type: primitive literal unions use their SQLite builder primitive', () => { + type LiteralSelect = { + id: number; + kind: 0 | 1; + role: 'admin' | 'member'; + }; + + defineReplicaEntity()({ + table: 'literal_items', + sourceKey: 'literal_items', + scope: 'group', + fields: { + id: serverId(), + kind: integer(), + role: text(), + }, + }); + }); + + it('type: literal unions reject mismatched primitive builders', () => { + type LiteralSelect = { + id: number; + kind: 0 | 1; + role: 'admin' | 'member'; + }; + + defineReplicaEntity()({ + table: 'literal_mismatch_items', + sourceKey: 'literal_mismatch_items', + scope: 'group', + fields: { + id: serverId(), + // @ts-expect-error — numeric literal unions require integer(). + kind: text(), + // @ts-expect-error — string literal unions require text(). + role: integer(), + }, + }); + }); }); diff --git a/projects/kit/offline/src/lib/offline-replica-schema.ts b/projects/kit/offline/src/lib/offline-replica-schema.ts index c3a2bbd..18fc743 100644 --- a/projects/kit/offline/src/lib/offline-replica-schema.ts +++ b/projects/kit/offline/src/lib/offline-replica-schema.ts @@ -87,14 +87,29 @@ type OfflineReplicaFieldDef = OfflineReplicaColumnDef | OfflineReplicaServerIdDe type StripNullish = Exclude; +type NormalizeReplicaColumnValue = T extends string + ? string + : T extends number + ? number + : T extends boolean + ? boolean + : T extends Date + ? string | Date + : T; + type IsNullableSelectValue = null extends T ? true : undefined extends T ? true : false; +type OfflineReplicaColumnDefForValue = IsNullableSelectValue extends true + ? OfflineReplicaColumnDef< + NormalizeReplicaColumnValue>, + { readonly [replicaNullableBrand]: 'nullable' } + > + : OfflineReplicaColumnDef, { readonly [replicaNullableBrand]: 'required' }>; + type OfflineReplicaFieldDefForKey, K extends keyof TSelect> = | (StripNullish extends number ? OfflineReplicaServerIdDef : never) | OfflineReplicaIgnoredDef - | (IsNullableSelectValue extends true - ? OfflineReplicaColumnDef, { readonly [replicaNullableBrand]: 'nullable' }> - : OfflineReplicaColumnDef); + | OfflineReplicaColumnDefForValue; type ExactSelectKeys, TFields> = Exclude extends never ? (Exclude extends never ? TFields : never) : never; diff --git a/projects/kit/offline/src/lib/offline-repository.spec.ts b/projects/kit/offline/src/lib/offline-repository.spec.ts index 8f1214e..0eed3bd 100644 --- a/projects/kit/offline/src/lib/offline-repository.spec.ts +++ b/projects/kit/offline/src/lib/offline-repository.spec.ts @@ -1072,6 +1072,159 @@ describe('IonicOfflineRepository', () => { }); }); + describe('replica serverId uniqueness', () => { + const scope = { userId: 1, groupId: 10 }; + const groupRow = { + sourceKey: 'test_group_items' as const, + serverId: 55, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'confirmed' as const, + }; + const userRow = { + sourceKey: 'test_items' as const, + serverId: 42, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'confirmed' as const, + }; + + it('group-scopedで別localIdに同じserverIdを割り当てるとrejectする', async () => { + await repository.transactReplica({ + putRows: [ + { + ...groupRow, + userId: 1, + groupId: 10, + localId: '019d-aaaa', + values: { id: 55, name: 'A' }, + }, + ], + }); + await expect( + repository.transactReplica({ + putRows: [ + { + ...groupRow, + userId: 1, + groupId: 10, + localId: '019d-bbbb', + values: { id: 55, name: 'B' }, + }, + ], + }), + ).rejects.toThrow('Offline replica serverId 55 is already mapped to localId 019d-aaaa.'); + }); + + it('user-scopedで別localIdに同じserverIdを割り当てるとrejectする', async () => { + await repository.transactReplica({ + putRows: [ + { + ...userRow, + userId: 1, + groupId: 10, + localId: '019d-aaaa', + values: { id: 42, title: 'A' }, + }, + ], + }); + await expect( + repository.transactReplica({ + putRows: [ + { + ...userRow, + userId: 1, + groupId: 10, + localId: '019d-bbbb', + values: { id: 42, title: 'B' }, + }, + ], + }), + ).rejects.toThrow('Offline replica serverId 42 is already mapped to localId 019d-aaaa.'); + }); + + it('同一transaction内のserverId重複は部分永続化せずrejectする', async () => { + await expect( + repository.transactReplica({ + putRows: [ + { + ...groupRow, + userId: 1, + groupId: 10, + localId: '019d-aaaa', + values: { id: 55, name: 'A' }, + }, + { + ...groupRow, + userId: 1, + groupId: 10, + localId: '019d-bbbb', + values: { id: 55, name: 'B' }, + }, + ], + }), + ).rejects.toThrow('Offline replica serverId 55 is already mapped to localId 019d-aaaa.'); + expect(await repository.getReplicaRow(scope, 'test_group_items', '019d-aaaa')).toBeNull(); + expect(await repository.getReplicaRow(scope, 'test_group_items', '019d-bbbb')).toBeNull(); + }); + + it('group-scopedは別groupなら同じserverIdを許容する', async () => { + await repository.transactReplica({ + putRows: [ + { + ...groupRow, + userId: 1, + groupId: 10, + localId: '019d-aaaa', + values: { id: 55, name: 'G10' }, + }, + { + ...groupRow, + userId: 1, + groupId: 11, + localId: '019d-bbbb', + values: { id: 55, name: 'G11' }, + }, + ], + }); + await expect(repository.getReplicaRowByServerId(scope, 'test_group_items', 55)).resolves.toMatchObject({ + localId: '019d-aaaa', + }); + await expect(repository.getReplicaRowByServerId({ userId: 1, groupId: 11 }, 'test_group_items', 55)).resolves.toMatchObject({ + localId: '019d-bbbb', + }); + }); + + it('user-scopedは別groupでも同じserverIdをrejectする', async () => { + await repository.transactReplica({ + putRows: [ + { + ...userRow, + userId: 1, + groupId: 10, + localId: '019d-aaaa', + values: { id: 42, title: 'G10' }, + }, + ], + }); + await expect( + repository.transactReplica({ + putRows: [ + { + ...userRow, + userId: 1, + groupId: 11, + localId: '019d-bbbb', + values: { id: 42, title: 'G11' }, + }, + ], + }), + ).rejects.toThrow('Offline replica serverId 42 is already mapped to localId 019d-aaaa.'); + }); + }); + describe('getReplicaRows', () => { const baseRow = { sourceKey: 'test_items', diff --git a/projects/kit/offline/src/lib/offline-repository.ts b/projects/kit/offline/src/lib/offline-repository.ts index 2d34cdc..1b30972 100644 --- a/projects/kit/offline/src/lib/offline-repository.ts +++ b/projects/kit/offline/src/lib/offline-repository.ts @@ -510,12 +510,17 @@ export class IonicOfflineRepository implements OfflineRepository { async #applyReplicaTransaction(transaction: OfflineReplicaTransaction, journal: boolean): Promise { await this.#assertReplicaSchemaLocked(); for (const row of transaction.putRows ?? []) this.#validateReplicaRow(row); - if (journal) await this.#storage.set(REPLICA_TRANSACTION_KEY, transaction); const [rows, commands, cursors] = await Promise.all([ this.#readRecord(ROWS_KEY), this.#readRecord(OUTBOX_KEY), this.#readRecord(CURSORS_KEY), ]); + const identityCheckRows = { ...rows }; + for (const row of transaction.putRows ?? []) { + this.#assertUniqueReplicaServerId(identityCheckRows, row); + identityCheckRows[this.#rowKey(row, row.sourceKey, row.localId)] = row; + } + if (journal) await this.#storage.set(REPLICA_TRANSACTION_KEY, transaction); for (const row of transaction.putRows ?? []) { const schema = this.#resolveReplicaEntitySchema(row.sourceKey); rows[this.#rowKey(row, row.sourceKey, row.localId)] = { @@ -598,6 +603,24 @@ export class IonicOfflineRepository implements OfflineRepository { if (row.confirmedValues !== null) encodeOfflineReplicaValues(schema, row.confirmedValues); } + #assertUniqueReplicaServerId(rows: Record, incoming: OfflineReplicaRow): void { + if (incoming.serverId === null) return; + const schema = this.#resolveReplicaEntitySchema(incoming.sourceKey); + const incomingKey = this.#rowKey(incoming, incoming.sourceKey, incoming.localId); + const collision = Object.entries(rows).find(([key, row]) => { + if (key === incomingKey) return false; + if (row.userId !== incoming.userId || row.sourceKey !== incoming.sourceKey || row.serverId !== incoming.serverId) { + return false; + } + return schema.scope === 'user' || row.groupId === incoming.groupId; + }); + if (collision) { + throw new Error( + `Offline replica serverId ${String(incoming.serverId)} is already mapped to localId ${collision[1].localId}.`, + ); + } + } + #resolveReplicaEntitySchema(sourceKey: string): OfflineReplicaEntitySchema> { const schema = this.#options.replicaSchema.entities.find((entity) => entity.sourceKey === sourceKey); if (!schema) throw new Error(`Unknown offline replica source key "${sourceKey}".`); diff --git a/projects/kit/offline/src/lib/offline-sync.service.spec.ts b/projects/kit/offline/src/lib/offline-sync.service.spec.ts index 35add84..7849b4b 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.spec.ts @@ -86,6 +86,13 @@ describe('OfflineSyncService', () => { item.userId === scope.userId && item.groupId === scope.groupId && item.sourceKey === sourceKey && item.localId === localId, ) ?? null, ), + getReplicaRowByServerId: vi.fn( + async (scope: OfflineScope, sourceKey: string, serverId: number) => + rows.find( + (item) => + item.userId === scope.userId && item.groupId === scope.groupId && item.sourceKey === sourceKey && item.serverId === serverId, + ) ?? null, + ), getReplicaCursor: vi.fn(async () => null), transactReplica: vi.fn(async (transaction) => { for (const row of transaction.putRows ?? []) { @@ -694,6 +701,161 @@ describe('OfflineSyncService', () => { await expect(service.flush()).rejects.toThrow('Offline replica serverId is immutable'); }); + it('enqueue時のserverId採用は初回pull前にreplica rowへ永続化する', async () => { + await service.enqueue( + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: '019d-adopted', + serverId: 38142, + operation: 'documents.update', + payload: { name: 'adopted' }, + optimisticValue: { name: 'adopted' }, + }, + { flush: false }, + ); + expect(rows[0]).toMatchObject({ + localId: '019d-adopted', + serverId: 38142, + confirmedValues: null, + syncState: 'pending', + }); + }); + + it('採用済みserverIdはflush時にdelete操作のexecutor targetへ渡す', async () => { + await service.enqueue( + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: '019d-adopted', + serverId: 38142, + operation: 'documents.delete', + payload: {}, + optimisticValue: {}, + }, + { flush: false }, + ); + connected.set(true); + execute.mockResolvedValueOnce({ removeReplica: true, response: null }); + await service.flush(); + expect(execute.mock.calls[0]?.[1]).toEqual({ localId: '019d-adopted', serverId: 38142 }); + }); + + it.each([0, -1, 1.5])('enqueue時の不正serverId %sは永続化前にrejectする', async (serverId) => { + await expect( + service.enqueue( + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: '019d-invalid', + serverId, + operation: 'documents.update', + payload: {}, + optimisticValue: {}, + }, + { flush: false }, + ), + ).rejects.toThrow(/invalid serverId/); + expect(commands).toEqual([]); + expect(rows).toEqual([]); + }); + + it('別localIdへ既存serverIdを割り当てようとするとrejectする', async () => { + rows.push({ + userId: 1, + groupId: 10, + sourceKey: 'documents', + localId: '019d-existing', + serverId: 38142, + values: {}, + confirmedValues: {}, + serverRevision: 1, + fetchedAt: 1, + syncState: 'confirmed', + }); + await expect( + service.enqueue( + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: '019d-new', + serverId: 38142, + operation: 'documents.update', + payload: {}, + optimisticValue: {}, + }, + { flush: false }, + ), + ).rejects.toThrow('Offline replica serverId 38142 is already mapped to localId 019d-existing.'); + expect(commands).toEqual([]); + }); + + it('同一localIdへのserverId再指定は許容する', async () => { + rows.push({ + userId: 1, + groupId: 10, + sourceKey: 'documents', + localId: '019d-same', + serverId: 38142, + values: { name: 'confirmed' }, + confirmedValues: { name: 'confirmed' }, + serverRevision: 1, + fetchedAt: 1, + syncState: 'confirmed', + }); + await service.enqueue( + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: '019d-same', + serverId: 38142, + operation: 'documents.update', + payload: { name: 'draft' }, + optimisticValue: { name: 'draft' }, + baseRevision: 1, + }, + { flush: false }, + ); + expect(rows[0]).toMatchObject({ localId: '019d-same', serverId: 38142, syncState: 'pending' }); + expect(commands).toHaveLength(1); + }); + + it('採用済み未確定rowはdiscardでoutboxとreplica rowを同時に除く', async () => { + const commandId = await service.enqueue( + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: '019d-adopted', + serverId: 38142, + operation: 'documents.update', + payload: { name: 'adopted' }, + optimisticValue: { name: 'adopted' }, + }, + { flush: false }, + ); + await service.discard(commandId, { flush: false }); + expect(commands).toEqual([]); + expect(rows).toEqual([]); + }); + + it('採用済み未確定rowはdiscardAllでoutboxとreplica rowを同時に除く', async () => { + await service.enqueue( + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: '019d-adopted', + serverId: 38142, + operation: 'documents.update', + payload: { name: 'adopted' }, + optimisticValue: { name: 'adopted' }, + }, + { flush: false }, + ); + await service.discardAllPending(); + expect(commands).toEqual([]); + expect(rows).toEqual([]); + }); + it('invalid serverRevisionはhard failする', async () => { execute.mockResolvedValueOnce({ serverRevision: Number.NaN, confirmedValues: {}, response: null }); await service.enqueue( @@ -736,7 +898,13 @@ describe('OfflineSyncService', () => { ], migrations: [], }); - const multiScopeSession = { userId: 1, scopes: [{ userId: 1, groupId: 10 }, { userId: 1, groupId: 11 }] as OfflineScope[] }; + const multiScopeSession = { + userId: 1, + scopes: [ + { userId: 1, groupId: 10 }, + { userId: 1, groupId: 11 }, + ] as OfflineScope[], + }; const userScopedSourceKeys = new Set(['test_items']); function compareCommands(left: OfflineCommand, right: OfflineCommand): number { @@ -768,9 +936,7 @@ describe('OfflineSyncService', () => { const repository = { initialize: vi.fn(async () => undefined), getCommands: vi.fn(async (scope: OfflineScope) => - commands - .filter((item) => item.userId === scope.userId && item.groupId === scope.groupId) - .sort(compareCommands), + commands.filter((item) => item.userId === scope.userId && item.groupId === scope.groupId).sort(compareCommands), ), getCommandsForUser: vi.fn(async (userId: number) => commands.filter((item) => item.userId === userId).sort(compareCommands)), putCommand: vi.fn(async (command: OfflineCommand) => { @@ -791,6 +957,13 @@ describe('OfflineSyncService', () => { const row = findReplicaRow(scope, sourceKey, localId); return row ? projectReplicaRow(row, scope) : null; }), + getReplicaRowByServerId: vi.fn(async (scope: OfflineScope, sourceKey: string, serverId: number) => { + const row = rows.find((item) => { + if (item.userId !== scope.userId || item.sourceKey !== sourceKey || item.serverId !== serverId) return false; + return userScopedSourceKeys.has(sourceKey) ? true : item.groupId === scope.groupId; + }); + return row ? projectReplicaRow(row, scope) : null; + }), getReplicaCursor: vi.fn(async () => null), transactReplica: vi.fn(async (transaction) => { for (const row of transaction.putRows ?? []) { diff --git a/projects/kit/offline/src/lib/offline-sync.service.ts b/projects/kit/offline/src/lib/offline-sync.service.ts index ca027a5..34f8ef1 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.ts @@ -16,6 +16,8 @@ export interface EnqueueOfflineCommand { aggregateType: string; /** Stable UUID generated by the client before the entity is first written. */ aggregateLocalId: string; + /** Known immutable server identity when adopting an entity before its first replica pull. */ + serverId?: number | null; operation: string; payload: T; /** Full local entity value committed to the replica before the command is exposed to the UI. */ @@ -153,11 +155,20 @@ export class OfflineSyncService { }; const entityType = this.#entityType(command); const existing = await this.#repository.getReplicaRow(scope, entityType, aggregateLocalId); + const initialServerId = this.#initialServerId(existing?.serverId ?? null, request.serverId); + if (initialServerId !== null) { + const mapped = await this.#repository.getReplicaRowByServerId(scope, entityType, initialServerId); + if (mapped !== null && mapped.localId !== aggregateLocalId) { + throw new Error( + `Offline replica serverId ${String(initialServerId)} is already mapped to localId ${mapped.localId}.`, + ); + } + } const optimisticRow: OfflineReplicaRow = { ...scope, sourceKey: entityType, localId: aggregateLocalId, - serverId: existing?.serverId ?? null, + serverId: initialServerId, values: optimisticValue, confirmedValues: existing?.confirmedValues ?? existing?.values ?? null, serverRevision: existing?.serverRevision ?? normalized.baseRevision, @@ -369,7 +380,7 @@ export class OfflineSyncService { const row = await this.#rowForCommand(command); if (!row) continue; const remaining = all.filter((item) => !discardedIds.has(item.commandId) && this.#aggregateKey(item) === key); - if (remaining.length === 0 && row.confirmedValues === null && row.serverId === null) { + if (remaining.length === 0 && row.confirmedValues === null) { removeRows.push(row); } else { putRows.push({ @@ -434,6 +445,10 @@ export class OfflineSyncService { return incoming; } + #initialServerId(current: number | null, incoming: number | null | undefined): number | null { + return incoming === null || incoming === undefined ? current : this.#resolvedServerId(current, incoming); + } + async #discoverScopes(generation = this.#generation): Promise { const session = await this.#context.getSession(); if (!this.#isCurrent(generation)) return false;