From 98580e4645582e8348964b0447749fe4afab1fc0 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Mon, 27 Jul 2026 22:35:37 +0900 Subject: [PATCH] feat(offline): model generated and natural row identities --- projects/kit/README.md | 82 ++- .../src/lib/offline-auth-bridge.spec.ts | 4 +- .../offline/src/lib/offline-auth-bridge.ts | 7 +- .../src/lib/offline-command-executor.ts | 55 +- .../lib/offline-coordinator.service.spec.ts | 4 +- .../src/lib/offline-coordinator.service.ts | 5 +- .../kit/offline/src/lib/offline-identity.ts | 184 +++++ .../src/lib/offline-natural-key.spec.ts | 199 +++++- .../lib/offline-replica-pull.service.spec.ts | 211 +++--- .../src/lib/offline-replica-pull.service.ts | 89 +-- .../src/lib/offline-replica-schema.spec.ts | 194 ++++-- .../offline/src/lib/offline-replica-schema.ts | 165 +++-- .../src/lib/offline-repository.spec.ts | 658 +++++++++--------- .../kit/offline/src/lib/offline-repository.ts | 376 ++++++---- .../src/lib/offline-session.service.ts | 15 +- .../src/lib/offline-sync.service.spec.ts | 605 ++++++++++------ .../offline/src/lib/offline-sync.service.ts | 260 ++++--- .../offline/src/lib/offline-test-helpers.ts | 22 + .../src/lib/sqlite-offline-repository.spec.ts | 571 ++++++++------- .../src/lib/sqlite-offline-repository.ts | 465 ++++++++----- projects/kit/offline/src/public-api.ts | 1 + .../kit/offline/type-tests/nonstrict-null.ts | 10 +- 22 files changed, 2650 insertions(+), 1532 deletions(-) create mode 100644 projects/kit/offline/src/lib/offline-identity.ts create mode 100644 projects/kit/offline/src/lib/offline-test-helpers.ts diff --git a/projects/kit/README.md b/projects/kit/README.md index fdbf53e..742db22 100644 --- a/projects/kit/README.md +++ b/projects/kit/README.md @@ -506,18 +506,24 @@ provideOffline({ }); ``` -Every SQLite entity uses an immutable client-generated UUID as `localId`; the outbox references only -`aggregateLocalId`. Remote identity is declared per entity: +Replica identity follows the product database: -| Remote identity | SQLite representation | Executor target | -| ------------------------------- | --------------------------------------------- | ----------------------------------------- | -| `serverId()` | nullable `server_id` | `{ localId, serverId }` | -| `naturalKey(['a', 'b'])` | mapped `a`/`b` columns; no `server_id` column | `{ localId, serverId: null, naturalKey }` | -| no remote identity (local-only) | mapped values only | `{ localId, serverId: null }` | +| Identity declaration | SQLite primary key | Row / executor identity | +| ---------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------- | +| `generatedId('integer' \| 'text')` | scope columns + immutable `local_id`; scoped nullable unique `server_id` | `{ kind: 'generated', localId, remoteId }` | +| `naturalKey(['a', 'b'])` | scope columns plus mapped `a`, `b` columns | `{ kind: 'natural', naturalKey: { a, b } }` | +| `identity: localOnly()` | scope columns + immutable `local_id` | `{ kind: 'local', localId }` | -A successful numeric create adds `serverId` without replacing `localId`. A natural key is derived from current -mapped values in declaration order; it is not duplicated as hidden row metadata. Entity projection and outbox -append/removal are committed in one local transaction. +Natural-key tables contain neither `local_id` nor `server_id`; their scoped key columns are the SQLite composite +`PRIMARY KEY`. Generated remote ids may be positive safe integers or non-empty text ids such as server-generated +UUIDs. A successful create adds `remoteId` without replacing `localId`. Local-only rows cannot enter the Outbox. +All TEXT identity components are opaque and compared byte-for-byte with SQLite's `BINARY` collation. The server +database must use the same binary equality, or the Hono/API converter must return one canonical form before the +value reaches `naturalKey(...)` or `generatedId('text')`. Do not pass case- or accent-insensitive primary keys +through unchanged: values that the server considers equal must first be canonicalized to identical TEXT. + +`OfflineScope.userId` accepts `number | string`. A type-tagged TEXT codec keeps numeric `7` and text `"7"` in +different metadata, manifest, cursor, Outbox, and entity boundaries. 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 @@ -525,8 +531,8 @@ revision. The server remains authoritative; SQLite is the durable local working For a DB row whose deletion is represented by absence, enqueue the same full row values with `replicaMutation: 'delete'`. The runtime stores a library-owned durable tombstone: normal `getReplicaRow()` and -`getReplicaRows()` reads stop returning the row immediately, while synchronization retains its immutable `localId`, -remote identity, confirmed baseline, and Outbox command. A successful tombstone acknowledgement removes the row +`getReplicaRows()` reads stop returning the row immediately, while synchronization retains its immutable generated +or natural identity, confirmed baseline, and Outbox command. A successful tombstone acknowledgement removes the row physically; retry, authorization failure, and conflict keep it hidden; discarding restores the latest confirmed baseline unless the server has also deleted it. @@ -539,7 +545,10 @@ tombstone for replay, lost-ACK reconciliation, conflict handling, and discard. await offlineSync.enqueue({ scopeId, aggregateType: 'favorites', - aggregateLocalId: favorite.localId, + identity: { + kind: 'natural', + naturalKey: { favFrom: favorite.values.favFrom, favTo: favorite.values.favTo }, + }, operation: 'favorite.delete', payload: { favTo: favorite.values.favTo }, optimisticValue: favorite.values, @@ -555,10 +564,11 @@ may lower these limits with `outboxLimits: { maxCommandsPerUser, maxBytesPerUser existing replica and Outbox remain unchanged and enqueue rejects with `OfflineOutboxCapacityError`, so the UI can ask the user to reconnect or resolve/discard an attention item. -Use `serverId()` for a positive safe-integer `AUTO_INCREMENT` key. Use `naturalKey([...])` when the product table's -remote identity is an existing composite primary key. Natural-key components must be required mapped `text()` or -`integer()` columns; nullable, ignored, JSON, empty declarations, duplicates, and mixed `serverId()`/`naturalKey()` -declarations reject. Text components reject NUL, malformed Unicode, and values larger than 1,024 UTF-8 bytes. +Use `generatedId('integer')` for a positive safe-integer `AUTO_INCREMENT` key and `generatedId('text')` for a +server-generated text key. Use `naturalKey([...])` for an existing single or composite natural primary key. +Natural-key components must be required mapped `text()` or `integer()` columns; nullable, ignored, JSON, empty +declarations, duplicates, and mixed generated/natural declarations reject. Text identities reject NUL, malformed +Unicode, and values larger than 1,024 UTF-8 bytes. 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 @@ -568,19 +578,18 @@ omitted id would otherwise make the executor interpret the row as a not-yet-crea await offlineSync.enqueue({ scopeId, aggregateType: 'items', - aggregateLocalId: localId, - serverId: existingApiItem.id, + identity: { kind: 'generated', localId, remoteId: 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 natural key, or assigning the same remote identity to another `localId`, rejects before persistence. -Web storage enforces the rule transactionally. SQLite uses a scoped partial unique index for `server_id` and a -scoped composite unique index over the natural-key columns: partition-scoped entities are unique per -user/partition/source, and user-scoped entities are unique per user/source across partitions. If an adopted row has +The mapping is immutable and unique inside its effective replica scope. Reassigning one generated `localId` to +another `remoteId`, or assigning the same remote identity to another `localId`, rejects before persistence. A +natural-key change is a delete of the old key plus an insert of the new key, never an update of the primary key. +Web storage enforces the same rule transactionally. SQLite uses a scoped partial unique index for generated +`server_id`; natural identity is enforced by the table's scoped composite primary key. 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. @@ -596,20 +605,20 @@ retention rule: without pending commands it removes the row, while a pending row The command adapter must send `commandId` as the server-side idempotency key. The server persists that key with the mutation and returns all keys represented by a delta row as `acknowledgedCommandIds`. This correlation is required: if the server commits a create/update/delete but its HTTP acknowledgement is lost, the next pull reconciles the -server result into the original `localId`, removes the acknowledged outbox prefix, and rebases later commands without -creating a second local identity. +server result into the same generated or natural identity, removes the acknowledged Outbox prefix, and rebases later +commands without creating a second local identity. Versioned replica schemas lock web and native storage. Web metadata stores `replicaSchemaVersion` and `replicaSchemaHash`; native stores the same pair in `offline_replica_schema_metadata`. Bump `version` for every intentional shape change and supply a complete one-step migration chain. Native runs each step's SQL `statements`; web runs `migrateWebRow`, which receives only `{ sourceKey, values, confirmedValues }` and may return the same -shape or `null` to delete a row. Identity and sync metadata (`localId`, `serverId`, scope, revision, +shape or `null` to delete a row. Identity and sync metadata (`identity`, scope, revision, `syncState`) stay outside the callback. The bundle fingerprint hashes `version`, entity layouts, and migration `fromVersion`/`statements` — never function bodies. -Changing a natural-key component list or its order changes the schema fingerprint and SQLite unique index. For a -released schema, bump `version`; native migration SQL must `DROP INDEX` and recreate the scoped composite index, and +Changing a natural-key component list or its order changes the schema fingerprint and SQLite primary key. For a +released schema, bump `version`; native migration SQL must rebuild the table with the new composite primary key, and `migrateWebRow` must preserve every natural-key value. Keep the transaction journal and schema-migration recovery records until the replacement rows, index, and metadata commit together. This natural-key API is currently unreleased, so adopting it before the first release needs no compatibility helper or legacy-row migration. @@ -618,10 +627,11 @@ unreleased, so adopting it before the first release needs no compatibility helpe import { defineOfflineReplicaSchema, defineReplicaEntity, + generatedId, integer, + localOnly, naturalKey, provideOffline, - serverId, text, } from '@rdlabo/ionic-angular-kit/offline'; @@ -633,7 +643,7 @@ const itemEntityV2 = defineReplicaEntity()({ sourceKey: 'items', scope: 'partition', fields: { - id: serverId(), + id: generatedId('integer'), title: text(), subtitle: text(), }, @@ -687,12 +697,14 @@ not a compatibility option: without strict null checking, TypeScript cannot dist 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)`. A remotely replicated entity declares either one numeric -`serverId()` or one ordered `naturalKey([...])`; a local-only projection declares neither. +a SQLite column, `generatedId('integer' | 'text')`, or `ignored(reason)`. A remotely replicated entity declares +either one generated-id field or one ordered `naturalKey([...])`; a local-only projection explicitly declares +`identity: localOnly()`. 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 numeric `serverId` remain dedicated -replica fields, natural identity is always derived from `values`, and ignored server fields are never persisted. +At runtime, `values` contains only the mapped column projection. Identity is a discriminated union: generated rows +carry `localId`/`remoteId`, natural rows carry only `naturalKey`, and local-only rows carry only `localId`. Ignored +server fields are never persisted. - **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-auth-bridge.spec.ts b/projects/kit/offline/src/lib/offline-auth-bridge.spec.ts index 978f844..a63ab7e 100644 --- a/projects/kit/offline/src/lib/offline-auth-bridge.spec.ts +++ b/projects/kit/offline/src/lib/offline-auth-bridge.spec.ts @@ -303,11 +303,11 @@ describe('createOfflineAuthBridge', () => { it('不正なremote identityをsessionへ永続化する前に拒否する', async () => { const { bridge, offline } = setupBridge({ - exchangeImpl: async () => ({ ...identity, userId: 0 }), + exchangeImpl: async () => ({ ...identity, userId: Number.NaN }), }); const { lease } = createLease(); - await expect(bridge.onAuthorized!(stateStub, lease)).rejects.toThrow('Offline remote identity userId must be a positive safe integer.'); + await expect(bridge.onAuthorized!(stateStub, lease)).rejects.toThrow('Offline principal id number must be a finite safe integer.'); expect(offline.prepareRemoteSession).not.toHaveBeenCalled(); }); }); diff --git a/projects/kit/offline/src/lib/offline-auth-bridge.ts b/projects/kit/offline/src/lib/offline-auth-bridge.ts index 1778a05..718d3df 100644 --- a/projects/kit/offline/src/lib/offline-auth-bridge.ts +++ b/projects/kit/offline/src/lib/offline-auth-bridge.ts @@ -1,4 +1,5 @@ import { inject } from '@angular/core'; +import { canonicalOfflinePrincipalId, type OfflinePrincipalId } from './offline-identity'; import { toObservable } from '@angular/core/rxjs-interop'; import type { RouterStateSnapshot } from '@angular/router'; import type { KitAuthAccessLease, KitAuthConfig, KitRemoteAccessRecovery } from '@rdlabo/ionic-angular-kit'; @@ -8,7 +9,7 @@ import { OfflineCoordinatorService } from './offline-coordinator.service'; /** Remote identity fields required to activate an offline session boundary. */ export interface OfflineRemoteIdentity { - readonly userId: number; + readonly userId: OfflinePrincipalId; readonly scopeIds: readonly string[]; readonly authSubject: string; } @@ -157,9 +158,7 @@ export function createOfflineAuthBridge } function assertOfflineRemoteIdentity(identity: OfflineRemoteIdentity): void { - if (!Number.isSafeInteger(identity.userId) || identity.userId <= 0) { - throw new Error('Offline remote identity userId must be a positive safe integer.'); - } + canonicalOfflinePrincipalId(identity.userId); if (!Array.isArray(identity.scopeIds) || identity.scopeIds.some((scopeId) => typeof scopeId !== 'string' || scopeId.length === 0)) { throw new Error('Offline remote identity scopeIds must contain only non-empty strings.'); } diff --git a/projects/kit/offline/src/lib/offline-command-executor.ts b/projects/kit/offline/src/lib/offline-command-executor.ts index f4e76c9..222ff37 100644 --- a/projects/kit/offline/src/lib/offline-command-executor.ts +++ b/projects/kit/offline/src/lib/offline-command-executor.ts @@ -1,31 +1,29 @@ import { InjectionToken } from '@angular/core'; import type { OfflineCommand, OfflineScope } from './offline-repository'; -import type { OfflineNaturalKey } from './offline-replica-schema'; +import type { OfflineCommandIdentity, OfflinePrincipalId, OfflineReplicaIdentity } from './offline-identity'; +import type { OfflineGeneratedRemoteId, OfflineNaturalKey } from './offline-replica-schema'; /** Server acknowledgement used to reconcile one optimistic local mutation. */ export interface OfflineCommandResult { - /** AUTO_INCREMENT id returned by a successful create. */ - serverId?: number; + /** Remote id returned by a successful generated-identity mutation. */ + remoteId?: OfflineGeneratedRemoteId; serverRevision?: string | number; /** Full server-confirmed domain values after applying the mutation. */ confirmedValues?: unknown; /** Removes the local replica row after a confirmed server delete. */ removeReplica?: boolean; /** - * Releases the deleted row's remote AUTO_INCREMENT identity while keeping + * Releases the deleted row's remote identity while keeping * its immutable local id for a queued recreate of the same logical target. */ - clearServerId?: boolean; + clearRemoteId?: boolean; response?: unknown; } -/** Target ids resolved from the local replica immediately before transport. */ -export interface OfflineCommandTarget { - localId: string; - serverId: number | null; - /** Composite identity derived from current replica values for a natural-key entity. */ - naturalKey?: OfflineNaturalKey; -} +/** Target identity resolved from the local replica immediately before transport. */ +export type OfflineCommandTarget = + | { readonly kind: 'generated'; readonly localId: string; readonly remoteId: OfflineGeneratedRemoteId | null } + | { readonly kind: 'natural'; readonly naturalKey: OfflineNaturalKey }; /** 不透明なoperationを製品APIへ送信し、local replicaへ投影するadapter。 */ /** Product adapter that sends commands and projects acknowledgements into entities. */ @@ -35,7 +33,7 @@ export interface OfflineCommandExecutor { withServerRevision(command: OfflineCommand, revision: string | number): OfflineCommand; /** * Removes the deleted remote row's revision from a queued recreate. - * Required only when `clearServerId` completes while later commands remain. + * Required only when `clearRemoteId` completes while later commands remain. */ withoutServerRevision?(command: OfflineCommand): OfflineCommand; } @@ -45,7 +43,7 @@ export const OFFLINE_COMMAND_EXECUTOR = new InjectionToken('OFFLINE_SYNC_CONTEXT'); + +/** Resolves the transport target from a replica row. */ +export function offlineCommandTargetFromReplicaRow(row: { readonly identity: OfflineReplicaIdentity }): OfflineCommandTarget { + if (row.identity.kind === 'natural') return { kind: 'natural', naturalKey: row.identity.naturalKey }; + if (row.identity.kind === 'local') throw new Error('Local-only replica rows cannot be synchronized.'); + return { kind: 'generated', localId: row.identity.localId, remoteId: row.identity.remoteId }; +} + +/** Resolves a command lookup identity from an enqueue request identity. */ +export function offlineCommandLookupIdentity(identity: EnqueueOfflineCommandIdentity): OfflineCommandIdentity { + if (identity.kind === 'generated') return { kind: 'generated', localId: identity.localId }; + return { kind: 'natural', naturalKey: identity.naturalKey }; +} + +/** Enqueue identity for generated entities. */ +export interface EnqueueOfflineGeneratedIdentity { + readonly kind: 'generated'; + readonly localId: string; + readonly remoteId?: OfflineGeneratedRemoteId | null; + readonly remoteIdHint?: OfflineGeneratedRemoteId | null; +} + +/** Enqueue identity for natural-key entities. */ +export interface EnqueueOfflineNaturalIdentity { + readonly kind: 'natural'; + readonly naturalKey: OfflineNaturalKey; +} + +export type EnqueueOfflineCommandIdentity = EnqueueOfflineGeneratedIdentity | EnqueueOfflineNaturalIdentity; diff --git a/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts b/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts index 6537849..80c4986 100644 --- a/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts @@ -3,7 +3,7 @@ import { TestBed } from '@angular/core/testing'; import { describe, expect, it, vi } from 'vitest'; import { OfflineCoordinatorService } from './offline-coordinator.service'; import { OfflineNetworkService } from './offline-network.service'; -import { OFFLINE_REPOSITORY } from './offline-repository'; +import { OFFLINE_REPOSITORY, type OfflineScope } from './offline-repository'; import { OfflineSessionService, type OfflineSessionManifest } from './offline-session.service'; import { OfflineSyncService } from './offline-sync.service'; @@ -166,7 +166,7 @@ describe('OfflineCoordinatorService', () => { it('preserves user scope 0 from remote activation through the first pull', async () => { let lastUserId: number | null = null; let manifest: OfflineSessionManifest | null = null; - const pull = vi.fn(async (_scope: { userId: number; scopeId: string }) => undefined); + const pull = vi.fn(async (_scope: OfflineScope) => undefined); const repository = { initialize: vi.fn(async () => undefined), getLastUserId: vi.fn(async () => lastUserId), diff --git a/projects/kit/offline/src/lib/offline-coordinator.service.ts b/projects/kit/offline/src/lib/offline-coordinator.service.ts index bc20e0f..b476ab7 100644 --- a/projects/kit/offline/src/lib/offline-coordinator.service.ts +++ b/projects/kit/offline/src/lib/offline-coordinator.service.ts @@ -1,4 +1,5 @@ import { inject, Injectable } from '@angular/core'; +import type { OfflinePrincipalId } from './offline-identity'; import { OfflineNetworkService } from './offline-network.service'; import { OFFLINE_REPOSITORY } from './offline-repository'; import { OfflineSessionService } from './offline-session.service'; @@ -29,14 +30,14 @@ export class OfflineCoordinatorService { await this.#sync.initialize(); } - async activateSession(userId: number, scopeIds: readonly string[], authSubject: string | null): Promise { + async activateSession(userId: OfflinePrincipalId, scopeIds: readonly string[], authSubject: string | null): Promise { if (!(await this.prepareRemoteSession(userId, scopeIds, authSubject))) return; await this.resumeRemoteSession(); } /** Installs a remotely verified identity without starting pull or outbox replay. */ prepareRemoteSession( - userId: number, + userId: OfflinePrincipalId, scopeIds: readonly string[], authSubject: string | null, authLease?: OfflineSessionTransitionLease, diff --git a/projects/kit/offline/src/lib/offline-identity.ts b/projects/kit/offline/src/lib/offline-identity.ts new file mode 100644 index 0000000..0c91d2d --- /dev/null +++ b/projects/kit/offline/src/lib/offline-identity.ts @@ -0,0 +1,184 @@ +import { + canonicalOfflineRemoteIdentity, + normalizeOfflineNaturalKey, + offlineNaturalKeyFromValues, + type OfflineGeneratedRemoteId, + type OfflineNaturalKey, + type OfflineReplicaEntitySchema, + type OfflineReplicaRemoteIdentity, +} from './offline-replica-schema'; + +/** Product-agnostic authenticated principal identifier. */ +export type OfflinePrincipalId = string | number; + +/** Type-tagged SQLite/web key; numeric 7 and text "7" never share a boundary. */ +export function canonicalOfflinePrincipalId(value: OfflinePrincipalId): string { + if (typeof value === 'number') { + if (!Number.isSafeInteger(value) || !Number.isFinite(value)) { + throw new Error('Offline principal id number must be a finite safe integer.'); + } + return `n:${value}`; + } + if (value.length === 0) throw new Error('Offline principal id string must not be empty.'); + return `s:${JSON.stringify(value)}`; +} + +/** Decodes a principal previously persisted by {@link canonicalOfflinePrincipalId}. */ +export function parseOfflinePrincipalId(value: string): OfflinePrincipalId { + if (value.startsWith('n:')) { + const numberValue = Number(value.slice(2)); + if (!Number.isSafeInteger(numberValue)) throw new Error('Stored offline principal id is invalid.'); + return numberValue; + } + if (value.startsWith('s:')) { + const stringValue: unknown = JSON.parse(value.slice(2)); + if (typeof stringValue !== 'string' || stringValue.length === 0) throw new Error('Stored offline principal id is invalid.'); + return stringValue; + } + throw new Error('Stored offline principal id has an unknown codec.'); +} + +/** Durable address of a generated or natural-key replica row. */ +export type OfflineReplicaIdentity = + | { readonly kind: 'generated'; readonly localId: string; readonly remoteId: OfflineGeneratedRemoteId | null } + | { readonly kind: 'natural'; readonly naturalKey: OfflineNaturalKey } + | { readonly kind: 'local'; readonly localId: string }; + +/** Outbox address. Generated ids are resolved to the latest server id immediately before transport. */ +export type OfflineCommandIdentity = + | { readonly kind: 'generated'; readonly localId: string } + | { readonly kind: 'natural'; readonly naturalKey: OfflineNaturalKey }; + +/** Stable lookup address for every replica row, including local-only projections. */ +export type OfflineReplicaAddress = OfflineCommandIdentity | { readonly kind: 'local'; readonly localId: string }; + +/** Stable storage key for one outbox or replica row identity. */ +export function canonicalOfflineCommandIdentity(identity: OfflineCommandIdentity): string { + if (identity.kind === 'generated') { + assertOfflineLocalId(identity.localId); + return `generated:${identity.localId}`; + } + return `natural:${canonicalOfflineNaturalKeyIdentity(identity.naturalKey)}`; +} + +/** Stable storage key for one materialized replica row identity. */ +export function canonicalOfflineReplicaIdentity(identity: OfflineReplicaIdentity): string { + if (identity.kind === 'generated') { + assertOfflineLocalId(identity.localId); + return `generated:${identity.localId}`; + } + if (identity.kind === 'local') { + assertOfflineLocalId(identity.localId); + return `local:${identity.localId}`; + } + return `natural:${canonicalOfflineNaturalKeyIdentity(identity.naturalKey)}`; +} + +/** JSON persisted in SQLite/web outbox rows. */ +export function serializeOfflineCommandIdentity(identity: OfflineCommandIdentity): string { + canonicalOfflineCommandIdentity(identity); + return JSON.stringify(identity); +} + +/** Parses durable outbox identity JSON. */ +export function parseOfflineCommandIdentity(value: unknown): OfflineCommandIdentity { + if (!isPlainObject(value)) throw new Error('Offline command identity must be a plain object.'); + if (value['kind'] === 'generated') { + assertOfflineLocalId(value['localId']); + return { kind: 'generated', localId: value['localId'] }; + } + if (value['kind'] === 'natural') { + if (!isPlainObject(value['naturalKey'])) throw new Error('Offline natural command identity requires naturalKey.'); + return { kind: 'natural', naturalKey: value['naturalKey'] as OfflineNaturalKey }; + } + throw new Error('Offline command identity must be generated or natural.'); +} + +/** Builds a generated replica identity. */ +export function offlineGeneratedReplicaIdentity(localId: string, remoteId: OfflineGeneratedRemoteId | null): OfflineReplicaIdentity { + assertOfflineLocalId(localId); + return { kind: 'generated', localId, remoteId }; +} + +/** Validates a durable local row id before it can enter either storage backend. */ +export function assertOfflineLocalId(value: unknown): asserts value is string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.includes('\0') || + value !== value.normalize('NFC') || + new TextEncoder().encode(value).byteLength > 255 + ) { + throw new Error('Offline localId must be a non-empty normalized string of at most 255 UTF-8 bytes without NUL.'); + } +} + +/** Builds a natural replica identity from current domain values. */ +export function offlineNaturalReplicaIdentity( + schema: OfflineReplicaEntitySchema>, + values: unknown, +): OfflineReplicaIdentity { + return { kind: 'natural', naturalKey: offlineNaturalKeyFromValues(schema, values)! }; +} + +/** Converts a materialized replica identity into an outbox address. */ +export function commandIdentityFromReplicaIdentity(identity: OfflineReplicaIdentity): OfflineCommandIdentity { + if (identity.kind === 'generated') return { kind: 'generated', localId: identity.localId }; + if (identity.kind === 'local') { + throw new Error('Local-only replica rows cannot be added to the Outbox.'); + } + return { kind: 'natural', naturalKey: identity.naturalKey }; +} + +/** Converts a materialized row identity into its immutable repository address. */ +export function replicaAddressFromIdentity(identity: OfflineReplicaIdentity): OfflineReplicaAddress { + if (identity.kind === 'natural') return { kind: 'natural', naturalKey: identity.naturalKey }; + return { kind: identity.kind, localId: identity.localId }; +} + +/** Whether a command identity addresses the given replica row. */ +export function commandIdentityMatchesReplicaRow( + schema: OfflineReplicaEntitySchema>, + row: { readonly identity: OfflineReplicaIdentity }, + commandIdentity: OfflineCommandIdentity, +): boolean { + if (commandIdentity.kind === 'generated') { + return row.identity.kind === 'generated' && row.identity.localId === commandIdentity.localId; + } + if (row.identity.kind !== 'natural') return false; + return ( + canonicalOfflineRemoteIdentity(schema, { naturalKey: row.identity.naturalKey }) === + canonicalOfflineRemoteIdentity(schema, { naturalKey: normalizeOfflineNaturalKey(schema, commandIdentity.naturalKey) }) + ); +} + +/** Remote identity used by pull reconciliation and uniqueness checks. */ +export function offlineReplicaRemoteIdentity( + schema: OfflineReplicaEntitySchema>, + identity: OfflineReplicaIdentity, +): OfflineReplicaRemoteIdentity | null { + if (schema.identity.kind === 'generated') { + return identity.kind === 'generated' && identity.remoteId !== null ? { remoteId: identity.remoteId } : null; + } + if (schema.identity.kind === 'naturalKey') { + return identity.kind === 'natural' ? { naturalKey: identity.naturalKey } : null; + } + return null; +} + +function canonicalOfflineNaturalKeyIdentity(naturalKey: OfflineNaturalKey): string { + return JSON.stringify( + Object.keys(naturalKey) + .sort() + .map((key) => { + const value = naturalKey[key]!; + return [key, typeof value === 'number' ? 'n' : 's', value]; + }), + ); +} + +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== 'object') return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} diff --git a/projects/kit/offline/src/lib/offline-natural-key.spec.ts b/projects/kit/offline/src/lib/offline-natural-key.spec.ts index d28a89b..89e1cfb 100644 --- a/projects/kit/offline/src/lib/offline-natural-key.spec.ts +++ b/projects/kit/offline/src/lib/offline-natural-key.spec.ts @@ -18,10 +18,13 @@ import { IonicOfflineRepository, OFFLINE_REPOSITORY, OFFLINE_SCHEMA_VERSION, + parseOfflineCommandIdentity, + serializeOfflineCommandIdentity, type OfflineCommand, type OfflineRepository, type OfflineScope, } from './offline-repository'; +import { naturalCommandIdentity, naturalReplicaIdentity } from './offline-test-helpers'; class MemoryStorage { readonly values = new Map(); @@ -47,10 +50,167 @@ const entity = defineReplicaEntity<{ favFrom: number; favTo: string; label: stri }); const schema = defineOfflineReplicaSchema({ version: 1, entities: [entity], migrations: [] }); +const threePartEntity = defineReplicaEntity<{ z: number; tenant: string; a: number; label: string }>()({ + table: 'three_part_keys', + sourceKey: 'three_part_keys', + scope: 'partition', + identity: naturalKey(['z', 'tenant', 'a']), + fields: { z: integer(), tenant: text(), a: integer(), label: text() }, +}); +const threePartSchema = defineOfflineReplicaSchema({ version: 1, entities: [threePartEntity], migrations: [] }); const scope: OfflineScope = { userId: 1, scopeId: '10' }; +const key42 = { favFrom: 7, favTo: '42' }; +const key43 = { favFrom: 7, favTo: '43' }; + +function naturalRow( + naturalKeyValues: { favFrom: number; favTo: string }, + label: string, + options: { syncState?: 'confirmed' | 'pending'; confirmedValues?: { favFrom: number; favTo: string; label: string } | null } = {}, +) { + return { + ...scope, + sourceKey: 'natural_favorites' as const, + identity: naturalReplicaIdentity(naturalKeyValues), + values: { ...naturalKeyValues, label }, + confirmedValues: options.confirmedValues ?? null, + serverRevision: null, + fetchedAt: 1, + syncState: options.syncState ?? ('pending' as const), + }; +} + +async function createRepository(replicaSchema = schema): Promise { + const storage = new MemoryStorage(); + const schemaHash = await sha256OfflineReplicaSchema(replicaSchema); + storage.values.set('offline:metadata', { + schemaVersion: OFFLINE_SCHEMA_VERSION, + lastUserId: null, + replicaSchemaVersion: replicaSchema.version, + replicaSchemaHash: schemaHash, + }); + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + IonicOfflineRepository, + { provide: KitStorageService, useValue: storage }, + { provide: OFFLINE_KIT_OPTIONS, useValue: { databaseName: 'test', replicaSchema } }, + { provide: OFFLINE_REPOSITORY, useExisting: IonicOfflineRepository }, + ], + }); + const repository = TestBed.inject(OFFLINE_REPOSITORY); + await repository.initialize(); + return repository; +} + +describe('natural-key replica identity', () => { + it('uses exact composite natural identity as the row PRIMARY KEY', async () => { + const repository = await createRepository(); + await repository.transactReplica({ putRows: [naturalRow(key42, 'A')] }); + + await expect(repository.getReplicaRow(scope, 'natural_favorites', naturalCommandIdentity(key42))).resolves.toMatchObject({ + identity: { kind: 'natural', naturalKey: key42 }, + values: { favFrom: 7, favTo: '42', label: 'A' }, + }); + await expect(repository.getReplicaRows(scope, 'natural_favorites')).resolves.toEqual([ + expect.objectContaining({ + identity: { kind: 'natural', naturalKey: key42 }, + }), + ]); + }); + + it('rejects rows whose identity naturalKey does not match values', async () => { + const repository = await createRepository(); + await expect( + repository.transactReplica({ + putRows: [ + { + ...naturalRow(key42, 'mismatch'), + identity: naturalReplicaIdentity(key42), + values: { favFrom: 8, favTo: '42', label: 'mismatch' }, + }, + ], + }), + ).rejects.toThrow('Offline replica identity naturalKey must match values for "natural_favorites".'); + await expect(repository.getReplicaRow(scope, 'natural_favorites', naturalCommandIdentity(key42))).resolves.toBeNull(); + }); + + it('treats natural key change as delete old row plus insert new row', async () => { + const repository = await createRepository(); + await repository.transactReplica({ putRows: [naturalRow(key42, 'A')] }); + await expect( + repository.transactReplica({ + putRows: [{ ...naturalRow(key42, 'changed'), values: { favFrom: 8, favTo: '42', label: 'changed' } }], + }), + ).rejects.toThrow('Offline replica identity naturalKey must match values for "natural_favorites".'); + + await repository.transactReplica({ + removeRows: [{ ...scope, sourceKey: 'natural_favorites', identity: naturalReplicaIdentity(key42) }], + putRows: [naturalRow(key43, 'B')], + }); + await expect(repository.getReplicaRows(scope, 'natural_favorites')).resolves.toEqual([ + expect.objectContaining({ identity: { kind: 'natural', naturalKey: key43 }, values: { favFrom: 7, favTo: '43', label: 'B' } }), + ]); + await expect(repository.getReplicaRow(scope, 'natural_favorites', naturalCommandIdentity(key42))).resolves.toBeNull(); + }); + + it('upserts the same natural key into one durable row', async () => { + const repository = await createRepository(); + await repository.transactReplica({ putRows: [naturalRow(key42, 'first')] }); + await repository.transactReplica({ + putRows: [naturalRow(key42, 'second', { syncState: 'confirmed', confirmedValues: { ...key42, label: 'second' } })], + }); + + await expect(repository.getReplicaRows(scope, 'natural_favorites')).resolves.toHaveLength(1); + await expect(repository.getReplicaRow(scope, 'natural_favorites', naturalCommandIdentity(key42))).resolves.toMatchObject({ + identity: { kind: 'natural', naturalKey: key42 }, + values: { favFrom: 7, favTo: '42', label: 'second' }, + confirmedValues: { favFrom: 7, favTo: '42', label: 'second' }, + syncState: 'confirmed', + }); + }); + + it('roundtrips natural command identity through outbox JSON', () => { + const identity = naturalCommandIdentity(key42); + const json = serializeOfflineCommandIdentity(identity); + expect(parseOfflineCommandIdentity(JSON.parse(json))).toEqual(identity); + }); + + it('supports an ordered three-part mixed key for DDL, lookup, upsert, ordering, and remove', async () => { + expect(threePartEntity.createTableSql[0]).toContain('PRIMARY KEY (_offline_user_id, _offline_scope_id, z, tenant, a)'); + const repository = await createRepository(threePartSchema); + const firstKey = { z: 2, tenant: 'tenant-a', a: 9 }; + const secondKey = { z: 10, tenant: 'tenant-a', a: 1 }; + const row = (key: typeof firstKey, label: string) => ({ + ...scope, + sourceKey: 'three_part_keys', + identity: naturalReplicaIdentity(key), + values: { ...key, label }, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'pending' as const, + }); + + await repository.transactReplica({ putRows: [row(secondKey, 'second'), row(firstKey, 'first')] }); + await repository.transactReplica({ putRows: [row(firstKey, 'updated')] }); + await expect(repository.getReplicaRow(scope, 'three_part_keys', naturalCommandIdentity(firstKey))).resolves.toMatchObject({ + values: { label: 'updated' }, + }); + await expect(repository.getReplicaRows(scope, 'three_part_keys')).resolves.toEqual([ + expect.objectContaining({ identity: naturalReplicaIdentity(firstKey) }), + expect.objectContaining({ identity: naturalReplicaIdentity(secondKey) }), + ]); + + await repository.transactReplica({ + removeRows: [{ ...scope, sourceKey: 'three_part_keys', identity: naturalReplicaIdentity(firstKey) }], + }); + await expect(repository.getReplicaRow(scope, 'three_part_keys', naturalCommandIdentity(firstKey))).resolves.toBeNull(); + }); +}); + describe('natural-key pull reconciliation', () => { - it('same-page lost ACK keeps UUID, pending tombstone conflicts, and same-kind natural identity reassignment rejects', async () => { + it('ACKs create, pending tombstone conflicts, and rejects immutable naturalKey reassignment', async () => { const storage = new MemoryStorage(); const schemaHash = await sha256OfflineReplicaSchema(schema); storage.values.set('offline:metadata', { @@ -66,7 +226,7 @@ describe('natural-key pull reconciliation', () => { changes: [ { sourceKey: 'natural_favorites', - naturalKey: { favFrom: 7, favTo: '42' }, + naturalKey: key42, serverRevision: 1, acknowledgedCommandIds: ['create-1'], values: { favFrom: 7, favTo: '42', label: 'intermediate' }, @@ -74,7 +234,7 @@ describe('natural-key pull reconciliation', () => { }, { sourceKey: 'natural_favorites', - naturalKey: { favFrom: 7, favTo: '42' }, + naturalKey: key42, serverRevision: 2, values: { favFrom: 7, favTo: '42', label: 'confirmed' }, deleted: false, @@ -89,7 +249,7 @@ describe('natural-key pull reconciliation', () => { changes: [ { sourceKey: 'natural_favorites', - naturalKey: { favFrom: 7, favTo: '42' }, + naturalKey: key42, serverRevision: 3, values: null, deleted: true, @@ -104,7 +264,7 @@ describe('natural-key pull reconciliation', () => { changes: [ { sourceKey: 'natural_favorites', - naturalKey: { favFrom: 7, favTo: '43' }, + naturalKey: key43, serverRevision: 4, acknowledgedCommandIds: ['update-1'], values: null, @@ -139,25 +299,14 @@ describe('natural-key pull reconciliation', () => { const service = TestBed.inject(OfflineReplicaPullService); await repository.initialize(); await repository.transactReplica({ - putRows: [ - { - ...scope, - sourceKey: 'natural_favorites', - localId: 'immutable-local-uuid', - serverId: null, - values: { favFrom: 7, favTo: '42', label: 'optimistic' }, - confirmedValues: null, - serverRevision: null, - fetchedAt: 1, - syncState: 'pending', - }, - ], + putRows: [naturalRow(key42, 'optimistic')], putCommands: [ { ...scope, commandId: 'create-1', aggregateType: 'natural_favorites', - aggregateLocalId: 'immutable-local-uuid', + sourceKey: 'natural_favorites', + identity: naturalCommandIdentity(key42), operation: 'create', payload: {}, optimisticValue: { favFrom: 7, favTo: '42', label: 'optimistic' }, @@ -175,9 +324,10 @@ describe('natural-key pull reconciliation', () => { await service.pull(scope); await expect(repository.getReplicaRows(scope, 'natural_favorites')).resolves.toEqual([ expect.objectContaining({ - localId: 'immutable-local-uuid', - serverId: null, + identity: { kind: 'natural', naturalKey: key42 }, values: { favFrom: 7, favTo: '42', label: 'confirmed' }, + confirmedValues: { favFrom: 7, favTo: '42', label: 'confirmed' }, + syncState: 'confirmed', }), ]); await expect(repository.getCommands(scope)).resolves.toEqual([]); @@ -185,7 +335,7 @@ describe('natural-key pull reconciliation', () => { await repository.transactReplica({ putRows: [ { - ...(await repository.getReplicaRow(scope, 'natural_favorites', 'immutable-local-uuid'))!, + ...(await repository.getReplicaRow(scope, 'natural_favorites', naturalCommandIdentity(key42)))!, values: { favFrom: 7, favTo: '42', label: 'pending edit' }, syncState: 'pending', }, @@ -195,7 +345,8 @@ describe('natural-key pull reconciliation', () => { ...scope, commandId: 'update-1', aggregateType: 'natural_favorites', - aggregateLocalId: 'immutable-local-uuid', + sourceKey: 'natural_favorites', + identity: naturalCommandIdentity(key42), operation: 'update', payload: {}, optimisticValue: { favFrom: 7, favTo: '42', label: 'pending edit' }, @@ -212,7 +363,7 @@ describe('natural-key pull reconciliation', () => { await service.pull(scope); await expect(repository.getReplicaRows(scope, 'natural_favorites')).resolves.toEqual([ expect.objectContaining({ - localId: 'immutable-local-uuid', + identity: { kind: 'natural', naturalKey: key42 }, values: { favFrom: 7, favTo: '42', label: 'pending edit' }, syncState: 'conflict', }), diff --git a/projects/kit/offline/src/lib/offline-replica-pull.service.spec.ts b/projects/kit/offline/src/lib/offline-replica-pull.service.spec.ts index b64dceb..067f326 100644 --- a/projects/kit/offline/src/lib/offline-replica-pull.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-replica-pull.service.spec.ts @@ -12,7 +12,15 @@ import { type OfflineReplicaPullRequest, } from './offline-replica-puller'; import { OfflineReplicaPullService } from './offline-replica-pull.service'; -import { defineOfflineReplicaSchema, defineReplicaEntity, serverId, sha256OfflineReplicaSchema, text } from './offline-replica-schema'; +import { generatedCommandIdentity, generatedReplicaIdentity } from './offline-test-helpers'; +import { + defineOfflineReplicaSchema, + defineReplicaEntity, + integer, + generatedId, + sha256OfflineReplicaSchema, + text, +} from './offline-replica-schema'; import { IonicOfflineRepository, OFFLINE_REPOSITORY, @@ -30,7 +38,7 @@ const testItemEntity = defineReplicaEntity()({ sourceKey: 'test_items', scope: 'user', fields: { - id: serverId(), + id: generatedId('integer'), title: text(), }, }); @@ -62,16 +70,16 @@ class MemoryStorage { } function itemChange( - serverIdValue: number, + remoteIdValue: number, title: string, options: Partial> = {}, ): OfflineReplicaChange { return { sourceKey: 'test_items', - serverId: serverIdValue, + remoteId: remoteIdValue, serverRevision: options.serverRevision ?? 1, acknowledgedCommandIds: options.acknowledgedCommandIds ?? [], - values: options.deleted ? null : (options.values ?? { id: serverIdValue, title }), + values: options.deleted ? null : (options.values ?? { id: remoteIdValue, title }), deleted: options.deleted ?? false, }; } @@ -193,10 +201,10 @@ describe('OfflineReplicaPullService', () => { expect(pull.mock.calls.map(([request]) => request.cursor)).toEqual(['cursor-v0', 'cursor-v1']); await expect(repository.getReplicaCursor(scope)).resolves.toEqual({ ...scope, cursor: 'cursor-v2' }); - await expect(repository.getReplicaRowByServerId(scope, 'test_items', 42)).resolves.toMatchObject({ + await expect(repository.getReplicaRowByRemoteId(scope, 'test_items', 42)).resolves.toMatchObject({ confirmedValues: { title: 'Page 1' }, }); - await expect(repository.getReplicaRowByServerId(scope, 'test_items', 43)).resolves.toMatchObject({ + await expect(repository.getReplicaRowByRemoteId(scope, 'test_items', 43)).resolves.toMatchObject({ confirmedValues: { title: 'Page 2' }, }); }); @@ -209,7 +217,12 @@ describe('OfflineReplicaPullService', () => { expect(transactReplica).toHaveBeenCalledOnce(); expect(transactReplica.mock.calls[0]?.[0]).toMatchObject({ - putRows: [expect.objectContaining({ serverId: 42, confirmedValues: { title: 'Created' } })], + putRows: [ + expect.objectContaining({ + identity: expect.objectContaining({ kind: 'generated', remoteId: 42 }), + confirmedValues: { title: 'Created' }, + }), + ], putCursors: [{ ...scope, cursor: 'cursor-v1' }], }); }); @@ -220,9 +233,10 @@ describe('OfflineReplicaPullService', () => { await service.pull(scope); - await expect(repository.getReplicaRow(scope, 'test_items', '019d0000-0000-7000-8000-000000000001')).resolves.toMatchObject({ - localId: '019d0000-0000-7000-8000-000000000001', - serverId: 42, + await expect( + repository.getReplicaRow(scope, 'test_items', generatedCommandIdentity('019d0000-0000-7000-8000-000000000001')), + ).resolves.toMatchObject({ + identity: { kind: 'generated', localId: '019d0000-0000-7000-8000-000000000001', remoteId: 42 }, sourceKey: 'test_items', syncState: 'confirmed', values: { title: 'Created' }, @@ -237,8 +251,7 @@ describe('OfflineReplicaPullService', () => { { ...scope, sourceKey: 'test_items', - localId: '019d-existing', - serverId: 42, + identity: { kind: 'generated', localId: '019d-existing', remoteId: 42 }, values: { id: 42, title: 'Old' }, confirmedValues: { id: 42, title: 'Old' }, serverRevision: 1, @@ -251,9 +264,8 @@ describe('OfflineReplicaPullService', () => { await service.pull(scope); - await expect(repository.getReplicaRow(scope, 'test_items', '019d-existing')).resolves.toMatchObject({ - localId: '019d-existing', - serverId: 42, + await expect(repository.getReplicaRow(scope, 'test_items', generatedCommandIdentity('019d-existing'))).resolves.toMatchObject({ + identity: { kind: 'generated', localId: '019d-existing', remoteId: 42 }, serverRevision: 2, values: { title: 'Updated' }, confirmedValues: { title: 'Updated' }, @@ -267,8 +279,7 @@ describe('OfflineReplicaPullService', () => { { ...scope, sourceKey: 'test_items', - localId: '019d-delete', - serverId: 42, + identity: { kind: 'generated', localId: '019d-delete', remoteId: 42 }, values: { id: 42, title: 'Gone' }, confirmedValues: { id: 42, title: 'Gone' }, serverRevision: 1, @@ -281,8 +292,8 @@ describe('OfflineReplicaPullService', () => { await service.pull(scope); - expect(await repository.getReplicaRow(scope, 'test_items', '019d-delete')).toBeNull(); - expect(await repository.getReplicaRowByServerId(scope, 'test_items', 42)).toBeNull(); + expect(await repository.getReplicaRow(scope, 'test_items', generatedCommandIdentity('019d-delete'))).toBeNull(); + expect(await repository.getReplicaRowByRemoteId(scope, 'test_items', 42)).toBeNull(); }); it('duplicate changeはlast-winsでcollapseする', async () => { @@ -348,23 +359,23 @@ describe('OfflineReplicaPullService', () => { ); }); - it('non-positive serverIdはrejectしcursorを進めない', async () => { + it('non-positive remoteIdはrejectしcursorを進めない', async () => { await expectPullRejectsPreservingCursor( () => pull.mockResolvedValueOnce( - page([{ ...itemChange(42, 'Created'), serverId: 0 } as unknown as OfflineReplicaChange], { nextCursor: 'cursor-v1' }), + page([{ ...itemChange(42, 'Created'), remoteId: 0 } as unknown as OfflineReplicaChange], { nextCursor: 'cursor-v1' }), ), - 'Offline replica pull page changes[0].serverId must be a positive integer.', + 'Offline replica pull page changes[0].remoteId must be a valid generated remote id.', ); }); - it('non-integer serverIdはrejectしcursorを進めない', async () => { + it('non-integer remoteIdはrejectしcursorを進めない', async () => { await expectPullRejectsPreservingCursor( () => pull.mockResolvedValueOnce( - page([{ ...itemChange(42, 'Created'), serverId: 42.5 } as unknown as OfflineReplicaChange], { nextCursor: 'cursor-v1' }), + page([{ ...itemChange(42, 'Created'), remoteId: 42.5 } as unknown as OfflineReplicaChange], { nextCursor: 'cursor-v1' }), ), - 'Offline replica pull page changes[0].serverId must be a positive integer.', + 'Offline replica pull page changes[0].remoteId must be a valid generated remote id.', ); }); @@ -400,7 +411,7 @@ describe('OfflineReplicaPullService', () => { [ { sourceKey: 'unknown_items', - serverId: 42, + remoteId: 42, serverRevision: 1, acknowledgedCommandIds: [], values: { id: 42, title: 'X' }, @@ -418,7 +429,7 @@ describe('OfflineReplicaPullService', () => { it('missing valuesはrejectしcursorを進めない', async () => { await repository.transactReplica({ putCursors: [{ ...scope, cursor: 'cursor-v0' }] }); pull.mockResolvedValueOnce( - page([{ sourceKey: 'test_items', serverId: 42, serverRevision: 1, acknowledgedCommandIds: [], values: null, deleted: false }], { + page([{ sourceKey: 'test_items', remoteId: 42, serverRevision: 1, acknowledgedCommandIds: [], values: null, deleted: false }], { nextCursor: 'cursor-v1', }), ); @@ -451,8 +462,7 @@ describe('OfflineReplicaPullService', () => { { ...scope, sourceKey: 'test_items', - localId: '019d-pending', - serverId: 42, + identity: { kind: 'generated', localId: '019d-pending', remoteId: 42 }, values: { id: 42, title: 'Optimistic draft' }, confirmedValues: { id: 42, title: 'Confirmed baseline' }, serverRevision: 1, @@ -465,7 +475,8 @@ describe('OfflineReplicaPullService', () => { ...scope, commandId: 'cmd-pending', aggregateType: 'test_items', - aggregateLocalId: '019d-pending', + sourceKey: 'test_items', + identity: { kind: 'generated', localId: '019d-pending' }, operation: 'test_items.update', payload: { title: 'Optimistic draft' }, optimisticValue: { id: 42, title: 'Optimistic draft' }, @@ -483,7 +494,7 @@ describe('OfflineReplicaPullService', () => { await service.pull(scope); - await expect(repository.getReplicaRow(scope, 'test_items', '019d-pending')).resolves.toMatchObject({ + await expect(repository.getReplicaRow(scope, 'test_items', generatedCommandIdentity('019d-pending'))).resolves.toMatchObject({ values: { title: 'Optimistic draft' }, confirmedValues: { title: 'Server truth' }, serverRevision: 2, @@ -498,8 +509,7 @@ describe('OfflineReplicaPullService', () => { { ...scope, sourceKey: 'test_items', - localId: '019d-conflict', - serverId: 42, + identity: { kind: 'generated', localId: '019d-conflict', remoteId: 42 }, values: { id: 42, title: 'Local edit' }, confirmedValues: { id: 42, title: 'Old confirmed' }, serverRevision: 1, @@ -512,7 +522,8 @@ describe('OfflineReplicaPullService', () => { ...scope, commandId: 'cmd-conflict', aggregateType: 'test_items', - aggregateLocalId: '019d-conflict', + sourceKey: 'test_items', + identity: { kind: 'generated', localId: '019d-conflict' }, operation: 'test_items.update', payload: { title: 'Local edit' }, optimisticValue: { id: 42, title: 'Local edit' }, @@ -530,7 +541,7 @@ describe('OfflineReplicaPullService', () => { await service.pull(scope); - await expect(repository.getReplicaRow(scope, 'test_items', '019d-conflict')).resolves.toMatchObject({ + await expect(repository.getReplicaRow(scope, 'test_items', generatedCommandIdentity('019d-conflict'))).resolves.toMatchObject({ syncState: 'conflict', confirmedValues: { title: 'Remote truth' }, serverRevision: 9, @@ -551,8 +562,7 @@ describe('OfflineReplicaPullService', () => { { ...scope, sourceKey: 'test_items', - localId: '019d-tombstone', - serverId: 42, + identity: { kind: 'generated', localId: '019d-tombstone', remoteId: 42 }, values: { id: 42, title: 'Pending delete' }, confirmedValues: { id: 42, title: 'Confirmed' }, serverRevision: 1, @@ -565,7 +575,8 @@ describe('OfflineReplicaPullService', () => { ...scope, commandId: 'cmd-tombstone', aggregateType: 'test_items', - aggregateLocalId: '019d-tombstone', + sourceKey: 'test_items', + identity: { kind: 'generated', localId: '019d-tombstone' }, operation: 'test_items.delete', payload: {}, optimisticValue: { id: 42, title: 'Pending delete' }, @@ -583,8 +594,8 @@ describe('OfflineReplicaPullService', () => { await service.pull(scope); - await expect(repository.getReplicaRow(scope, 'test_items', '019d-tombstone')).resolves.toMatchObject({ - localId: '019d-tombstone', + await expect(repository.getReplicaRow(scope, 'test_items', generatedCommandIdentity('019d-tombstone'))).resolves.toMatchObject({ + identity: { kind: 'generated', localId: '019d-tombstone', remoteId: 42 }, syncState: 'conflict', serverRevision: 2, }); @@ -596,7 +607,7 @@ describe('OfflineReplicaPullService', () => { retryAt: null, }), ]); - expect(await repository.getReplicaRowByServerId(scope, 'test_items', 42)).not.toBeNull(); + expect(await repository.getReplicaRowByRemoteId(scope, 'test_items', 42)).not.toBeNull(); }); describe('lost ACK correlation', () => { @@ -606,8 +617,7 @@ describe('OfflineReplicaPullService', () => { { ...scope, sourceKey: 'test_items', - localId, - serverId: null, + identity: generatedReplicaIdentity(localId, null), values: { id: 0, title: 'Draft create' }, confirmedValues: null, serverRevision: null, @@ -620,7 +630,8 @@ describe('OfflineReplicaPullService', () => { ...scope, commandId: 'cmd-create', aggregateType: 'test_items', - aggregateLocalId: localId, + sourceKey: 'test_items', + identity: generatedCommandIdentity(localId), operation: 'test_items.create', payload: { title: 'Draft create' }, optimisticValue: { id: 0, title: 'Draft create' }, @@ -636,7 +647,7 @@ describe('OfflineReplicaPullService', () => { }); } - it('create lost ACKは既存localId行をreconcileしserverIdを割り当ててcommandを除去する', async () => { + it('create lost ACKは既存localId行をreconcileしremoteIdを割り当ててcommandを除去する', async () => { await seedPendingCreate(); pull.mockResolvedValueOnce( page([itemChange(42, 'Created', { serverRevision: 1, acknowledgedCommandIds: ['cmd-create'] })], { nextCursor: 'cursor-v1' }), @@ -644,9 +655,8 @@ describe('OfflineReplicaPullService', () => { await service.pull(scope); - await expect(repository.getReplicaRow(scope, 'test_items', '019d-create')).resolves.toMatchObject({ - localId: '019d-create', - serverId: 42, + await expect(repository.getReplicaRow(scope, 'test_items', generatedCommandIdentity('019d-create'))).resolves.toMatchObject({ + identity: { kind: 'generated', localId: '019d-create', remoteId: 42 }, confirmedValues: { title: 'Created' }, syncState: 'confirmed', }); @@ -660,8 +670,7 @@ describe('OfflineReplicaPullService', () => { { ...scope, sourceKey: 'test_items', - localId: '019d-update', - serverId: 42, + identity: { kind: 'generated', localId: '019d-update', remoteId: 42 }, values: { id: 42, title: 'Follow-up edit' }, confirmedValues: { id: 42, title: 'Confirmed baseline' }, serverRevision: 1, @@ -674,7 +683,8 @@ describe('OfflineReplicaPullService', () => { ...scope, commandId: 'cmd-update-1', aggregateType: 'test_items', - aggregateLocalId: '019d-update', + sourceKey: 'test_items', + identity: { kind: 'generated', localId: '019d-update' }, operation: 'test_items.update', payload: { title: 'First edit' }, optimisticValue: { id: 42, title: 'First edit' }, @@ -690,7 +700,8 @@ describe('OfflineReplicaPullService', () => { ...scope, commandId: 'cmd-update-2', aggregateType: 'test_items', - aggregateLocalId: '019d-update', + sourceKey: 'test_items', + identity: { kind: 'generated', localId: '019d-update' }, operation: 'test_items.update', payload: { title: 'Follow-up edit' }, optimisticValue: { id: 42, title: 'Follow-up edit' }, @@ -712,9 +723,8 @@ describe('OfflineReplicaPullService', () => { await service.pull(scope); - await expect(repository.getReplicaRow(scope, 'test_items', '019d-update')).resolves.toMatchObject({ - localId: '019d-update', - serverId: 42, + await expect(repository.getReplicaRow(scope, 'test_items', generatedCommandIdentity('019d-update'))).resolves.toMatchObject({ + identity: { kind: 'generated', localId: '019d-update', remoteId: 42 }, values: { title: 'Follow-up edit' }, confirmedValues: { title: 'First edit applied' }, serverRevision: 2, @@ -731,8 +741,7 @@ describe('OfflineReplicaPullService', () => { { ...scope, sourceKey: 'test_items', - localId: '019d-delete-ack', - serverId: 42, + identity: { kind: 'generated', localId: '019d-delete-ack', remoteId: 42 }, values: { id: 42, title: 'Pending delete' }, confirmedValues: { id: 42, title: 'Confirmed' }, serverRevision: 1, @@ -745,7 +754,8 @@ describe('OfflineReplicaPullService', () => { ...scope, commandId: 'cmd-delete', aggregateType: 'test_items', - aggregateLocalId: '019d-delete-ack', + sourceKey: 'test_items', + identity: { kind: 'generated', localId: '019d-delete-ack' }, operation: 'test_items.delete', payload: {}, optimisticValue: { id: 42, title: 'Pending delete' }, @@ -767,8 +777,8 @@ describe('OfflineReplicaPullService', () => { await service.pull(scope); - expect(await repository.getReplicaRow(scope, 'test_items', '019d-delete-ack')).toBeNull(); - expect(await repository.getReplicaRowByServerId(scope, 'test_items', 42)).toBeNull(); + expect(await repository.getReplicaRow(scope, 'test_items', generatedCommandIdentity('019d-delete-ack'))).toBeNull(); + expect(await repository.getReplicaRowByRemoteId(scope, 'test_items', 42)).toBeNull(); expect(await repository.getCommands(scope)).toEqual([]); }); @@ -778,8 +788,7 @@ describe('OfflineReplicaPullService', () => { { ...scope, sourceKey: 'test_items', - localId: '019d-delete-superseded', - serverId: 42, + identity: { kind: 'generated', localId: '019d-delete-superseded', remoteId: 42 }, values: { id: 42, title: 'Following upsert' }, confirmedValues: { id: 42, title: 'Old confirmed baseline' }, serverRevision: 1, @@ -792,7 +801,8 @@ describe('OfflineReplicaPullService', () => { ...scope, commandId: 'cmd-delete-ack', aggregateType: 'test_items', - aggregateLocalId: '019d-delete-superseded', + sourceKey: 'test_items', + identity: { kind: 'generated', localId: '019d-delete-superseded' }, operation: 'test_items.delete', payload: {}, optimisticValue: { id: 42, title: 'Pending delete' }, @@ -809,7 +819,8 @@ describe('OfflineReplicaPullService', () => { ...scope, commandId: 'cmd-following-upsert', aggregateType: 'test_items', - aggregateLocalId: '019d-delete-superseded', + sourceKey: 'test_items', + identity: { kind: 'generated', localId: '019d-delete-superseded' }, operation: 'test_items.update', payload: { title: 'Following upsert' }, optimisticValue: { id: 42, title: 'Following upsert' }, @@ -833,7 +844,9 @@ describe('OfflineReplicaPullService', () => { await service.pull(scope); - await expect(repository.getReplicaRow(scope, 'test_items', '019d-delete-superseded')).resolves.toMatchObject({ + await expect( + repository.getReplicaRow(scope, 'test_items', generatedCommandIdentity('019d-delete-superseded')), + ).resolves.toMatchObject({ values: { title: 'Following upsert' }, confirmedValues: null, serverRevision: 3, @@ -844,14 +857,13 @@ describe('OfflineReplicaPullService', () => { ]); }); - it('same-kind serverId tombstone ACKが別idを返した場合はlocal identityを再割当しない', async () => { + it('same-kind remoteId tombstone ACKが別idを返した場合はlocal identityを再割当しない', async () => { await repository.transactReplica({ putRows: [ { ...scope, sourceKey: 'test_items', - localId: '019d-server-id-immutable', - serverId: 42, + identity: { kind: 'generated', localId: '019d-server-id-immutable', remoteId: 42 }, values: { id: 42, title: 'Pending delete' }, confirmedValues: { id: 42, title: 'Confirmed' }, serverRevision: 1, @@ -865,7 +877,8 @@ describe('OfflineReplicaPullService', () => { ...scope, commandId: 'cmd-server-id-immutable', aggregateType: 'test_items', - aggregateLocalId: '019d-server-id-immutable', + sourceKey: 'test_items', + identity: { kind: 'generated', localId: '019d-server-id-immutable' }, operation: 'test_items.delete', payload: {}, optimisticValue: { id: 42, title: 'Pending delete' }, @@ -884,9 +897,11 @@ describe('OfflineReplicaPullService', () => { page([itemChange(43, 'Wrong identity', { deleted: true, serverRevision: 2, acknowledgedCommandIds: ['cmd-server-id-immutable'] })]), ); - await expect(service.pull(scope)).rejects.toThrow('Replica serverId is immutable: current=42, incoming=43.'); - await expect(repository.getReplicaRowIncludingPendingDelete?.(scope, 'test_items', '019d-server-id-immutable')).resolves.toMatchObject({ - serverId: 42, + await expect(service.pull(scope)).rejects.toThrow('Replica remote id is immutable: current=42, incoming=43.'); + await expect( + repository.getReplicaRowIncludingPendingDelete?.(scope, 'test_items', generatedCommandIdentity('019d-server-id-immutable')), + ).resolves.toMatchObject({ + identity: { kind: 'generated', localId: '019d-server-id-immutable', remoteId: 42 }, visibility: 'pending_delete', }); }); @@ -905,7 +920,7 @@ describe('OfflineReplicaPullService', () => { await service.pull(scope); - await expect(repository.getReplicaRow(scope, 'test_items', '019d-create')).resolves.toMatchObject({ + await expect(repository.getReplicaRow(scope, 'test_items', generatedCommandIdentity('019d-create'))).resolves.toMatchObject({ confirmedValues: { title: 'Final' }, serverRevision: 2, syncState: 'confirmed', @@ -919,8 +934,7 @@ describe('OfflineReplicaPullService', () => { { ...scope, sourceKey: 'test_items', - localId: '019d-lost-update', - serverId: 42, + identity: { kind: 'generated', localId: '019d-lost-update', remoteId: 42 }, values: { id: 42, title: 'Follow-up edit' }, confirmedValues: { id: 42, title: 'Baseline' }, serverRevision: 1, @@ -933,7 +947,8 @@ describe('OfflineReplicaPullService', () => { ...scope, commandId: 'cmd-ack-lost', aggregateType: 'test_items', - aggregateLocalId: '019d-lost-update', + sourceKey: 'test_items', + identity: { kind: 'generated', localId: '019d-lost-update' }, operation: 'test_items.update', payload: { title: 'First edit' }, optimisticValue: { id: 42, title: 'First edit' }, @@ -949,7 +964,8 @@ describe('OfflineReplicaPullService', () => { ...scope, commandId: 'cmd-following', aggregateType: 'test_items', - aggregateLocalId: '019d-lost-update', + sourceKey: 'test_items', + identity: { kind: 'generated', localId: '019d-lost-update' }, operation: 'test_items.update', payload: { title: 'Follow-up edit' }, optimisticValue: { id: 42, title: 'Follow-up edit' }, @@ -981,7 +997,7 @@ describe('OfflineReplicaPullService', () => { await service.pull(scope); - await expect(repository.getReplicaRow(scope, 'test_items', '019d-lost-update')).resolves.toMatchObject({ + await expect(repository.getReplicaRow(scope, 'test_items', generatedCommandIdentity('019d-lost-update'))).resolves.toMatchObject({ values: { title: 'Follow-up edit' }, confirmedValues: { title: 'Other device edit' }, serverRevision: 3, @@ -1003,8 +1019,7 @@ describe('OfflineReplicaPullService', () => { { ...scope, sourceKey: 'test_items', - localId: '019d-other-acks', - serverId: 42, + identity: { kind: 'generated', localId: '019d-other-acks', remoteId: 42 }, values: { id: 42, title: 'Local edit' }, confirmedValues: { id: 42, title: 'Baseline' }, serverRevision: 1, @@ -1017,7 +1032,8 @@ describe('OfflineReplicaPullService', () => { ...scope, commandId: 'cmd-local', aggregateType: 'test_items', - aggregateLocalId: '019d-other-acks', + sourceKey: 'test_items', + identity: { kind: 'generated', localId: '019d-other-acks' }, operation: 'test_items.update', payload: { title: 'Local edit' }, optimisticValue: { id: 42, title: 'Local edit' }, @@ -1049,7 +1065,7 @@ describe('OfflineReplicaPullService', () => { await service.pull(scope); - await expect(repository.getReplicaRow(scope, 'test_items', '019d-other-acks')).resolves.toMatchObject({ + await expect(repository.getReplicaRow(scope, 'test_items', generatedCommandIdentity('019d-other-acks'))).resolves.toMatchObject({ confirmedValues: { title: 'Other device edit 2' }, serverRevision: 3, syncState: 'pending', @@ -1065,8 +1081,7 @@ describe('OfflineReplicaPullService', () => { { ...scope, sourceKey: 'test_items', - localId: '019d-skip', - serverId: 42, + identity: { kind: 'generated', localId: '019d-skip', remoteId: 42 }, values: { id: 42, title: 'Second edit' }, confirmedValues: { id: 42, title: 'Baseline' }, serverRevision: 1, @@ -1079,7 +1094,8 @@ describe('OfflineReplicaPullService', () => { ...scope, commandId: 'cmd-first', aggregateType: 'test_items', - aggregateLocalId: '019d-skip', + sourceKey: 'test_items', + identity: { kind: 'generated', localId: '019d-skip' }, operation: 'test_items.update', payload: { title: 'First edit' }, optimisticValue: { id: 42, title: 'First edit' }, @@ -1095,7 +1111,8 @@ describe('OfflineReplicaPullService', () => { ...scope, commandId: 'cmd-second', aggregateType: 'test_items', - aggregateLocalId: '019d-skip', + sourceKey: 'test_items', + identity: { kind: 'generated', localId: '019d-skip' }, operation: 'test_items.update', payload: { title: 'Second edit' }, optimisticValue: { id: 42, title: 'Second edit' }, @@ -1125,8 +1142,7 @@ describe('OfflineReplicaPullService', () => { { ...scope, sourceKey: 'test_items', - localId: '019d-local-a', - serverId: null, + identity: { kind: 'generated', localId: '019d-local-a', remoteId: null }, values: { id: 0, title: 'Pending create A' }, confirmedValues: null, serverRevision: null, @@ -1136,8 +1152,7 @@ describe('OfflineReplicaPullService', () => { { ...scope, sourceKey: 'test_items', - localId: '019d-local-b', - serverId: 99, + identity: { kind: 'generated', localId: '019d-local-b', remoteId: 99 }, values: { id: 99, title: 'Existing remote' }, confirmedValues: { id: 99, title: 'Existing remote' }, serverRevision: 1, @@ -1150,7 +1165,8 @@ describe('OfflineReplicaPullService', () => { ...scope, commandId: 'cmd-create-a', aggregateType: 'test_items', - aggregateLocalId: '019d-local-a', + sourceKey: 'test_items', + identity: { kind: 'generated', localId: '019d-local-a' }, operation: 'test_items.create', payload: { title: 'Pending create A' }, optimisticValue: { id: 0, title: 'Pending create A' }, @@ -1180,8 +1196,7 @@ describe('OfflineReplicaPullService', () => { { ...scope, sourceKey: 'test_items', - localId: '019d-external', - serverId: 42, + identity: { kind: 'generated', localId: '019d-external', remoteId: 42 }, values: { id: 42, title: 'Local baseline' }, confirmedValues: { id: 42, title: 'Local baseline' }, serverRevision: 1, @@ -1195,7 +1210,7 @@ describe('OfflineReplicaPullService', () => { [ { sourceKey: 'test_items', - serverId: 42, + remoteId: 42, serverRevision: 2, values: { id: 42, title: 'Remote edit' }, deleted: false, @@ -1207,7 +1222,7 @@ describe('OfflineReplicaPullService', () => { await service.pull(scope); - await expect(repository.getReplicaRow(scope, 'test_items', '019d-external')).resolves.toMatchObject({ + await expect(repository.getReplicaRow(scope, 'test_items', generatedCommandIdentity('019d-external'))).resolves.toMatchObject({ values: { title: 'Remote edit' }, confirmedValues: { title: 'Remote edit' }, serverRevision: 2, @@ -1244,9 +1259,9 @@ describe('OfflineReplicaPullService', () => { getCommands: vi.fn(async () => []), transactReplica: vi.fn(async () => undefined), getReplicaRow: vi.fn(async () => null), - getReplicaRowByServerId: vi.fn(async () => null), + getReplicaRowByRemoteId: vi.fn(async () => null), getReplicaRowByRemoteIdentity: vi.fn(async (_scope, _sourceKey, identity) => { - if (identity.serverId === undefined) throw new Error('Natural identity unsupported'); + if (identity.remoteId === undefined) throw new Error('Natural identity unsupported'); return null; }), }, diff --git a/projects/kit/offline/src/lib/offline-replica-pull.service.ts b/projects/kit/offline/src/lib/offline-replica-pull.service.ts index 0fc1e2d..193d194 100644 --- a/projects/kit/offline/src/lib/offline-replica-pull.service.ts +++ b/projects/kit/offline/src/lib/offline-replica-pull.service.ts @@ -1,8 +1,14 @@ import { inject, Injectable } from '@angular/core'; import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; -import { OFFLINE_COMMAND_HOOKS } from './offline-command-hooks'; import { OFFLINE_COMMAND_EXECUTOR } from './offline-command-executor'; import { OFFLINE_REPLICA_PULLER, type OfflineReplicaChange, type OfflineReplicaPullPage } from './offline-replica-puller'; +import { + canonicalOfflineCommandIdentity, + commandIdentityFromReplicaIdentity, + commandIdentityMatchesReplicaRow, + offlineGeneratedReplicaIdentity, + offlineNaturalReplicaIdentity, +} from './offline-identity'; import { canonicalOfflineRemoteIdentity, normalizeOfflineNaturalKey, @@ -33,7 +39,6 @@ export class OfflineReplicaPullService { readonly #repository = inject(OFFLINE_REPOSITORY); readonly #options = inject(OFFLINE_KIT_OPTIONS); readonly #puller = inject(OFFLINE_REPLICA_PULLER); - readonly #hooks = inject(OFFLINE_COMMAND_HOOKS); readonly #executor = inject(OFFLINE_COMMAND_EXECUTOR); #schemaHash: Promise | null = null; @@ -69,49 +74,46 @@ export class OfflineReplicaPullService { .map((commandId) => { const command = commands.find((candidate) => candidate.commandId === commandId); if (!command) return null; - if (this.#hooks.entityType(command) !== change.sourceKey) { + if (command.sourceKey !== change.sourceKey) { throw new Error(`Acknowledged command "${commandId}" does not target "${change.sourceKey}".`); } return command; }) .filter((command): command is OfflineCommand => command !== null); - const acknowledgedLocalIds = new Set(acknowledged.map((command) => command.aggregateLocalId)); - if (acknowledgedLocalIds.size > 1) { - throw new Error(`Acknowledged commands for "${change.sourceKey}" target multiple local rows.`); + const acknowledgedIdentities = new Set(acknowledged.map((command) => canonicalOfflineCommandIdentity(command.identity))); + if (acknowledgedIdentities.size > 1) { + throw new Error(`Acknowledged commands for "${change.sourceKey}" target multiple replica identities.`); } const acknowledgedCommand = acknowledged[0]; const acknowledgedScope = acknowledgedCommand ? { userId: acknowledgedCommand.userId, scopeId: acknowledgedCommand.scopeId } : scope; const acknowledgedRow = acknowledgedCommand - ? await ( - this.#repository.getReplicaRowIncludingPendingDelete?.( - acknowledgedScope, - change.sourceKey, - acknowledgedCommand.aggregateLocalId, - ) ?? - this.#repository.getReplicaRow( - acknowledgedScope, - change.sourceKey, - acknowledgedCommand.aggregateLocalId, - ) - ) + ? await (this.#repository.getReplicaRowIncludingPendingDelete?.( + acknowledgedScope, + change.sourceKey, + acknowledgedCommand.identity, + ) ?? this.#repository.getReplicaRow(acknowledgedScope, change.sourceKey, acknowledgedCommand.identity)) : null; if (acknowledgedCommand && !acknowledgedRow) { throw new Error(`Acknowledged command "${acknowledgedCommand.commandId}" has no local replica row.`); } const identity = this.#identity(change); const serverRow = await this.#repository.getReplicaRowByRemoteIdentity(scope, change.sourceKey, identity); - if (acknowledgedRow && serverRow && acknowledgedRow.localId !== serverRow.localId) { - if (identity.serverId !== undefined) { - throw new Error(`Server id ${identity.serverId} is already mapped to another local replica row.`); + if ( + acknowledgedRow && + serverRow && + !commandIdentityMatchesReplicaRow(schema, acknowledgedRow, commandIdentityFromReplicaIdentity(serverRow.identity)) + ) { + if (identity.remoteId !== undefined) { + throw new Error(`Server id ${String(identity.remoteId)} is already mapped to another local replica row.`); } throw new Error(`Remote identity for "${change.sourceKey}" is already mapped to another local replica row.`); } const existing = acknowledgedRow ?? serverRow; const related = existing ? commands.filter( - (command) => this.#hooks.entityType(command) === change.sourceKey && command.aggregateLocalId === existing.localId, + (command) => command.sourceKey === change.sourceKey && commandIdentityMatchesReplicaRow(schema, existing, command.identity), ) : []; const hasPending = related.length > 0; @@ -125,7 +127,7 @@ export class OfflineReplicaPullService { if (change.deleted) { if (!existing) continue; if (!hasPending) { - removeRows.push(existing); + removeRows.push({ ...existing, identity: existing.identity }); continue; } putRows.push({ @@ -146,8 +148,10 @@ export class OfflineReplicaPullService { putRows.push({ ...scope, sourceKey: change.sourceKey, - localId: crypto.randomUUID(), - serverId: identity.serverId ?? null, + identity: + schema.identity.kind === 'naturalKey' + ? offlineNaturalReplicaIdentity(schema, confirmedValues) + : offlineGeneratedReplicaIdentity(crypto.randomUUID(), identity.remoteId ?? null), values: confirmedValues, confirmedValues, serverRevision: change.serverRevision, @@ -217,13 +221,15 @@ export class OfflineReplicaPullService { throw new Error(`${label}.deleted must be a boolean.`); } const schema = this.#entitySchema(change['sourceKey']); - if (schema.identity.kind === 'serverId') { - const serverId = change['serverId']; - if (typeof serverId !== 'number' || !Number.isSafeInteger(serverId) || serverId <= 0) { - throw new Error(`${label}.serverId must be a positive integer.`); + if (schema.identity.kind === 'generated') { + const remoteId = change['remoteId']; + const validInteger = typeof remoteId === 'number' && Number.isSafeInteger(remoteId) && remoteId > 0; + const validText = typeof remoteId === 'string' && remoteId.length > 0; + if (schema.identity.affinity === 'INTEGER' ? !validInteger : !validText) { + throw new Error(`${label}.remoteId must be a valid generated remote id.`); } if (change['naturalKey'] !== undefined) { - throw new Error(`${label}.naturalKey must be omitted for a serverId entity.`); + throw new Error(`${label}.naturalKey must be omitted for a generated entity.`); } } try { @@ -267,9 +273,9 @@ export class OfflineReplicaPullService { #validatedValues(schema: OfflineReplicaEntitySchema>, change: OfflineReplicaChange): unknown { if (change.values === null) { throw new Error( - change.serverId === undefined + change.remoteId === undefined ? `Offline replica change "${change.sourceKey}" is missing values.` - : `Offline replica change "${change.sourceKey}"/${change.serverId} is missing values.`, + : `Offline replica change "${change.sourceKey}"/${String(change.remoteId)} is missing values.`, ); } const values = projectOfflineReplicaValues(schema, change.values); @@ -363,7 +369,7 @@ export class OfflineReplicaPullService { }); } } else { - removeRows.push(row); + removeRows.push({ ...row, identity: row.identity }); } return; } @@ -373,7 +379,7 @@ export class OfflineReplicaPullService { this.#assertIdentityAssignment(schema, row, this.#identity(change)); putRows.push({ ...row, - serverId: change.serverId ?? null, + identity: row.identity.kind === 'generated' ? { ...row.identity, remoteId: change.remoteId ?? row.identity.remoteId } : row.identity, values: following.length > 0 ? following.at(-1)!.optimisticValue : confirmedValues, confirmedValues, serverRevision: change.serverRevision, @@ -384,7 +390,7 @@ export class OfflineReplicaPullService { } #identity(change: OfflineReplicaChange): OfflineReplicaRemoteIdentity { - return change.serverId !== undefined ? { serverId: change.serverId } : { naturalKey: change.naturalKey! }; + return change.remoteId !== undefined ? { remoteId: change.remoteId } : { naturalKey: change.naturalKey! }; } #assertIdentityAssignment( @@ -392,14 +398,17 @@ export class OfflineReplicaPullService { row: OfflineReplicaRow, incoming: OfflineReplicaRemoteIdentity, ): void { - if (schema.identity.kind === 'serverId') { - const serverId = incoming.serverId!; - if (row.serverId !== null && row.serverId !== serverId) { - throw new Error(`Replica serverId is immutable: current=${row.serverId}, incoming=${serverId}.`); + if (schema.identity.kind === 'generated') { + const remoteId = incoming.remoteId!; + if (row.identity.kind !== 'generated') { + throw new Error(`Replica generated identity is required for "${schema.sourceKey}".`); + } + if (row.identity.remoteId !== null && row.identity.remoteId !== remoteId) { + throw new Error(`Replica remote id is immutable: current=${String(row.identity.remoteId)}, incoming=${String(remoteId)}.`); } return; } - const current = offlineNaturalKeyFromValues(schema, row.values); + const current = row.identity.kind === 'natural' ? row.identity.naturalKey : offlineNaturalKeyFromValues(schema, row.values); if ( current !== null && canonicalOfflineRemoteIdentity(schema, { naturalKey: current }) !== canonicalOfflineRemoteIdentity(schema, incoming) 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 96f6598..3ff7411 100644 --- a/projects/kit/offline/src/lib/offline-replica-schema.spec.ts +++ b/projects/kit/offline/src/lib/offline-replica-schema.spec.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/consistent-type-definitions */ import { describe, expect, it } from 'vitest'; +import { canonicalOfflinePrincipalId, parseOfflinePrincipalId } from './offline-identity'; import { booleanColumn, canonicalOfflineRemoteIdentity, @@ -11,11 +12,12 @@ import { ignored, integer, json, + localOnly, naturalKey, nullable, projectOfflineReplicaValues, real, - serverId, + generatedId, sha256OfflineReplicaSchema, text, type OfflineReplicaNaturalKeyDef, @@ -37,7 +39,7 @@ const sampleSchema = defineReplicaEntity()({ sourceKey: 'sample_items', scope: 'partition', fields: { - id: serverId(), + id: generatedId('integer'), title: text(), notes: nullable(text()), amount: real(), @@ -65,7 +67,7 @@ describe('offline-replica-schema runtime', () => { ]); expect(sampleSchema.fields.find((field) => field.sourceKey === 'id')).toEqual({ sourceKey: 'id', - policy: 'serverId', + policy: 'remoteId', sqliteColumnName: 'server_id', affinity: 'INTEGER', storageKind: null, @@ -108,13 +110,14 @@ describe('offline-replica-schema runtime', () => { it('generates deterministic CREATE TABLE SQL and scoped server_id index', () => { expect(sampleSchema.createTableSql).toEqual([ `CREATE TABLE IF NOT EXISTS sample_items ( - local_id TEXT NOT NULL, - _offline_user_id INTEGER NOT NULL, + _offline_user_id TEXT NOT NULL, _offline_scope_id TEXT NOT NULL, + local_id TEXT NOT NULL, server_id INTEGER, _offline_confirmed_json TEXT, _offline_server_revision_json TEXT, _offline_sync_state TEXT NOT NULL, + _offline_visibility TEXT NOT NULL DEFAULT 'present', _offline_fetched_at INTEGER NOT NULL, active INTEGER NOT NULL, amount REAL NOT NULL, @@ -122,7 +125,7 @@ describe('offline-replica-schema runtime', () => { payload TEXT NOT NULL, title TEXT NOT NULL, updated_at TEXT NOT NULL, - PRIMARY KEY (local_id) + PRIMARY KEY (_offline_user_id, _offline_scope_id, local_id) )`, 'CREATE UNIQUE INDEX IF NOT EXISTS uq_sample_items_server_id ON sample_items (_offline_user_id, _offline_scope_id, server_id) WHERE server_id IS NOT NULL', ]); @@ -135,22 +138,23 @@ describe('offline-replica-schema runtime', () => { sourceKey: 'user_notes', scope: 'user', fields: { - id: serverId(), + id: generatedId('integer'), title: text(), }, }); expect(userScopedSchema.createTableSql).toEqual([ `CREATE TABLE IF NOT EXISTS user_notes ( + _offline_user_id TEXT NOT NULL, local_id TEXT NOT NULL, - _offline_user_id INTEGER NOT NULL, server_id INTEGER, _offline_confirmed_json TEXT, _offline_server_revision_json TEXT, _offline_sync_state TEXT NOT NULL, + _offline_visibility TEXT NOT NULL DEFAULT 'present', _offline_fetched_at INTEGER NOT NULL, title TEXT NOT NULL, - PRIMARY KEY (local_id) + PRIMARY KEY (_offline_user_id, local_id) )`, 'CREATE UNIQUE INDEX IF NOT EXISTS uq_user_notes_server_id ON user_notes (_offline_user_id, server_id) WHERE server_id IS NOT NULL', ]); @@ -158,18 +162,30 @@ describe('offline-replica-schema runtime', () => { it('exposes a deterministic schema fingerprint input', () => { expect(sampleSchema.schemaFingerprintInput).toBe( - 'table=sample_items|source=sample_items|scope=partition|hasServerId=1|fields=active:column:active:INTEGER:booleanColumn:required;amount:column:amount:REAL:real:required;id:serverId:server_id:INTEGER:nullable;notes:column:notes:TEXT:text:nullable;payload:column:payload:TEXT:json:required;title:column:title:TEXT:text:required;transientFlag:ignored:server-only cache flag;updatedAt:column:updated_at:TEXT:datetime:required', + 'table=sample_items|source=sample_items|scope=partition|identity=generated:id:INTEGER|identityCodec=v2|fields=active:column:active:INTEGER:booleanColumn:required;amount:column:amount:REAL:real:required;id:remoteId:server_id:INTEGER:nullable;notes:column:notes:TEXT:text:nullable;payload:column:payload:TEXT:json:required;title:column:title:TEXT:text:required;transientFlag:ignored:server-only cache flag;updatedAt:column:updated_at:TEXT:datetime:required', ); }); - it('hidden tombstone storage is a core migration and intentionally does not alter product DDL or schema fingerprint', async () => { - expect(sampleSchema.createTableSql[0]).not.toContain('_offline_visibility'); + it('hidden tombstone storage is part of the initial product DDL and schema fingerprint', async () => { + expect(sampleSchema.createTableSql[0]).toContain('_offline_visibility'); expect(sampleSchema.schemaFingerprintInput).toBe( - 'table=sample_items|source=sample_items|scope=partition|hasServerId=1|fields=active:column:active:INTEGER:booleanColumn:required;amount:column:amount:REAL:real:required;id:serverId:server_id:INTEGER:nullable;notes:column:notes:TEXT:text:nullable;payload:column:payload:TEXT:json:required;title:column:title:TEXT:text:required;transientFlag:ignored:server-only cache flag;updatedAt:column:updated_at:TEXT:datetime:required', + 'table=sample_items|source=sample_items|scope=partition|identity=generated:id:INTEGER|identityCodec=v2|fields=active:column:active:INTEGER:booleanColumn:required;amount:column:amount:REAL:real:required;id:remoteId:server_id:INTEGER:nullable;notes:column:notes:TEXT:text:nullable;payload:column:payload:TEXT:json:required;title:column:title:TEXT:text:required;transientFlag:ignored:server-only cache flag;updatedAt:column:updated_at:TEXT:datetime:required', ); - expect(await sha256OfflineReplicaSchema(defineOfflineReplicaSchema({ version: 2, entities: [sampleSchema, userNotesSchema], migrations: [ - { fromVersion: 1, statements: ['ALTER TABLE sample_items ADD COLUMN legacy_flag INTEGER NOT NULL DEFAULT 0'], migrateWebRow: (row) => row }, - ] }))).toBe('7da9ce9faa5b749cc66827c70e11eb68d9c2ba660304468086fb6ba401dc672e'); + expect( + await sha256OfflineReplicaSchema( + defineOfflineReplicaSchema({ + version: 2, + entities: [sampleSchema, userNotesSchema], + migrations: [ + { + fromVersion: 1, + statements: ['ALTER TABLE sample_items ADD COLUMN legacy_flag INTEGER NOT NULL DEFAULT 0'], + migrateWebRow: (row) => row, + }, + ], + }), + ), + ).toBe('ccf882c366b942ba6e0811db50e11fa0fa81b25073fbbdfc932412f225adb617'); }); it('rejects invalid table and reserved column identifiers', () => { @@ -179,7 +195,7 @@ describe('offline-replica-schema runtime', () => { table: 'Bad-Table', sourceKey: 'items', scope: 'user', - fields: { id: serverId(), title: text() }, + fields: { id: generatedId('integer'), title: text() }, }), ).toThrow('Replica table "Bad-Table" must match ^[a-z][a-z0-9_]*$.'); @@ -189,7 +205,7 @@ describe('offline-replica-schema runtime', () => { sourceKey: 'items', scope: 'user', fields: { - id: serverId(), + id: generatedId('integer'), title: { kind: 'column', affinity: 'TEXT', storageKind: 'text', columnName: 'local_id', nullable: false }, }, }), @@ -203,32 +219,34 @@ describe('offline-replica-schema runtime', () => { table: 'items', sourceKey: 'items', scope: 'user', - fields: { id: serverId(), flag: ignored(' ') }, + fields: { id: generatedId('integer'), flag: ignored(' ') }, }), ).toThrow('Replica ignored field "flag" requires a reason.'); }); - it('rejects more than one serverId field', () => { + it('rejects more than one remoteId field', () => { type Select = { id: number; altId: number }; expect(() => defineReplicaEntity()({ table: 'items', sourceKey: 'items', scope: 'user', + identity: localOnly(), fields: { title: text() }, }); + expect(schema.identity).toEqual({ kind: 'localOnly', sourceKeys: [] }); expect(schema.fields).toEqual([ expect.objectContaining({ sourceKey: 'title', @@ -236,11 +254,25 @@ describe('offline-replica-schema runtime', () => { sqliteColumnName: 'title', }), ]); - expect(schema.createTableSql[0]).not.toContain('server_id'); - expect(schema.schemaFingerprintInput).toContain('hasServerId=0'); + expect(schema.createTableSql).toEqual([ + `CREATE TABLE IF NOT EXISTS items ( + _offline_user_id TEXT NOT NULL, + local_id TEXT NOT NULL, + _offline_confirmed_json TEXT, + _offline_server_revision_json TEXT, + _offline_sync_state TEXT NOT NULL, + _offline_visibility TEXT NOT NULL DEFAULT 'present', + _offline_fetched_at INTEGER NOT NULL, + title TEXT NOT NULL, + PRIMARY KEY (_offline_user_id, local_id) +)`, + ]); + expect(schema.schemaFingerprintInput).toBe( + 'table=items|source=items|scope=user|identity=localOnly|identityCodec=v2|fields=title:column:title:TEXT:text:required', + ); }); - it('materializes ordered naturalKey identity with a scope-unique physical-column index', () => { + it('materializes ordered naturalKey identity with a scoped composite primary key', () => { type Select = { favFrom: number; favTo: string; label: string }; const schema = defineReplicaEntity()({ + table: 'text_id_items', + sourceKey: 'text_id_items', + scope: 'user', + fields: { id: generatedId('text'), title: text() }, + }); + + expect(schema.fields.find((field) => field.sourceKey === 'id')).toMatchObject({ + policy: 'remoteId', + sqliteColumnName: 'server_id', + affinity: 'TEXT', + }); + expect(schema.createTableSql).toEqual([ + `CREATE TABLE IF NOT EXISTS text_id_items ( + _offline_user_id TEXT NOT NULL, + local_id TEXT NOT NULL, + server_id TEXT, + _offline_confirmed_json TEXT, + _offline_server_revision_json TEXT, + _offline_sync_state TEXT NOT NULL, + _offline_visibility TEXT NOT NULL DEFAULT 'present', + _offline_fetched_at INTEGER NOT NULL, + title TEXT NOT NULL, + PRIMARY KEY (_offline_user_id, local_id) +)`, + 'CREATE UNIQUE INDEX IF NOT EXISTS uq_text_id_items_server_id ON text_id_items (_offline_user_id, server_id) WHERE server_id IS NOT NULL', + ]); + expect(schema.schemaFingerprintInput).toBe( + 'table=text_id_items|source=text_id_items|scope=user|identity=generated:id:TEXT|identityCodec=v2|fields=id:remoteId:server_id:TEXT:nullable;title:column:title:TEXT:text:required', + ); + expect(canonicalOfflineRemoteIdentity(schema, { remoteId: 'abc-uuid' })).toBe('remoteId:s:abc-uuid'); + }); + + it('distinguishes numeric 7 from text "7" in canonical principal ids', () => { + const fromNumber = canonicalOfflinePrincipalId(7); + const fromString = canonicalOfflinePrincipalId('7'); + + expect(fromNumber).not.toBe(fromString); + expect(fromNumber).toBe('n:7'); + expect(fromString).toBe('s:"7"'); + expect(parseOfflinePrincipalId(fromNumber)).toBe(7); + expect(parseOfflinePrincipalId(fromString)).toBe('7'); }); }); @@ -311,7 +401,7 @@ const sampleRowValues = { }; describe('encodeOfflineReplicaValues', () => { - it('encodes column fields in deterministic descriptor order and ignores serverId/ignored source keys', () => { + it('encodes column fields in deterministic descriptor order and ignores remoteId/ignored source keys', () => { const encoded = encodeOfflineReplicaValues(sampleSchema, sampleRowValues); expect(Object.keys(encoded)).toEqual(['active', 'amount', 'notes', 'payload', 'title', 'updated_at']); @@ -403,7 +493,7 @@ const sampleColumnValues = { }; describe('decodeOfflineReplicaValues', () => { - it('decodes column fields keyed by source property names and ignores serverId/ignored columns', () => { + it('decodes column fields keyed by source property names and ignores remoteId/ignored columns', () => { const encoded = encodeOfflineReplicaValues(sampleSchema, sampleRowValues); const decoded = decodeOfflineReplicaValues(sampleSchema, { ...encoded, @@ -493,7 +583,7 @@ describe('encodeOfflineReplicaValues round-trip', () => { }); describe('projectOfflineReplicaValues', () => { - it('projects column fields and omits serverId and ignored source keys', () => { + it('projects column fields and omits remoteId and ignored source keys', () => { expect(projectOfflineReplicaValues(sampleSchema, sampleRowValues)).toEqual(sampleColumnValues); expect(Object.keys(projectOfflineReplicaValues(sampleSchema, sampleRowValues))).not.toContain('id'); expect(Object.keys(projectOfflineReplicaValues(sampleSchema, sampleRowValues))).not.toContain('transientFlag'); @@ -522,7 +612,7 @@ const userNotesSchema = defineReplicaEntity()({ sourceKey: 'user_notes', scope: 'user', fields: { - id: serverId(), + id: generatedId('integer'), title: text(), }, }); @@ -571,7 +661,7 @@ describe('offline-replica-schema bundle runtime', () => { it('computes a stable 64-character lowercase sha256 digest', async () => { const digest = await sha256OfflineReplicaSchema(schemaBundle); expect(digest).toMatch(/^[0-9a-f]{64}$/); - expect(digest).toBe('7da9ce9faa5b749cc66827c70e11eb68d9c2ba660304468086fb6ba401dc672e'); + expect(digest).toBe('ccf882c366b942ba6e0811db50e11fa0fa81b25073fbbdfc932412f225adb617'); }); it('changes the bundle fingerprint when storageKind changes from integer to booleanColumn while affinity stays INTEGER', async () => { @@ -583,7 +673,7 @@ describe('offline-replica-schema bundle runtime', () => { sourceKey: 'flag_items', scope: 'user', fields: { - id: serverId(), + id: generatedId('integer'), flag: integer(), }, }); @@ -592,7 +682,7 @@ describe('offline-replica-schema bundle runtime', () => { sourceKey: 'flag_items', scope: 'user', fields: { - id: serverId(), + id: generatedId('integer'), flag: booleanColumn(), }, }); @@ -663,7 +753,7 @@ describe('offline-replica-schema bundle runtime', () => { sourceKey: 'sample_items', scope: 'user', fields: { - id: serverId(), + id: generatedId('integer'), title: text(), }, }); @@ -739,7 +829,7 @@ describe('offline-replica-schema types', () => { sourceKey: 'compile_items', scope: 'partition', fields: { - id: serverId(), + id: generatedId('integer'), title: text(), notes: nullable(text()), }, @@ -751,7 +841,7 @@ describe('offline-replica-schema types', () => { scope: 'partition', // @ts-expect-error — `notes` is missing from the field map. fields: { - id: serverId(), + id: generatedId('integer'), title: text(), }, }); @@ -762,7 +852,7 @@ describe('offline-replica-schema types', () => { scope: 'partition', // @ts-expect-error — `extra` is not part of the select shape. fields: { - id: serverId(), + id: generatedId('integer'), title: text(), notes: nullable(text()), extra: text(), @@ -776,7 +866,7 @@ describe('offline-replica-schema types', () => { sourceKey: 'compile_items', scope: 'partition', fields: { - id: serverId(), + id: generatedId('integer'), title: text(), // @ts-expect-error — nullable select properties require nullable(...). notes: text(), @@ -788,7 +878,7 @@ describe('offline-replica-schema types', () => { sourceKey: 'compile_items', scope: 'partition', fields: { - id: serverId(), + id: generatedId('integer'), // @ts-expect-error — non-null select properties reject nullable(...). title: nullable(text()), notes: nullable(text()), @@ -800,22 +890,22 @@ describe('offline-replica-schema types', () => { sourceKey: 'compile_items', scope: 'partition', fields: { - id: serverId(), + id: generatedId('integer'), title: text(), notes: nullable(text()), }, }); }); - it('type: serverId() applies only to numeric source properties', () => { + it('type: generatedId() applies only to numeric source properties', () => { type StringIdSelect = { id: string; title: string }; defineReplicaEntity()({ table: 'items', sourceKey: 'items', scope: 'user', fields: { - // @ts-expect-error — serverId() cannot map a string property. - id: serverId(), + // @ts-expect-error — generatedId() cannot map a string property. + id: generatedId('integer'), title: text(), }, }); @@ -833,7 +923,7 @@ describe('offline-replica-schema types', () => { sourceKey: 'literal_items', scope: 'partition', fields: { - id: serverId(), + id: generatedId('integer'), kind: integer(), role: text(), }, @@ -852,7 +942,7 @@ describe('offline-replica-schema types', () => { sourceKey: 'literal_mismatch_items', scope: 'partition', fields: { - id: serverId(), + id: generatedId('integer'), // @ts-expect-error — numeric literal unions require integer(). kind: text(), // @ts-expect-error — string literal unions require text(). diff --git a/projects/kit/offline/src/lib/offline-replica-schema.ts b/projects/kit/offline/src/lib/offline-replica-schema.ts index 3251eb1..0a46ad1 100644 --- a/projects/kit/offline/src/lib/offline-replica-schema.ts +++ b/projects/kit/offline/src/lib/offline-replica-schema.ts @@ -13,7 +13,7 @@ export type OfflineReplicaStorageKind = 'text' | 'integer' | 'real' | 'booleanCo export type OfflineReplicaEntityScope = 'user' | 'partition'; /** Field projection policy for a source-model property. */ -export type OfflineReplicaFieldPolicy = 'column' | 'serverId' | 'ignored'; +export type OfflineReplicaFieldPolicy = 'column' | 'remoteId' | 'ignored'; /** Canonical value types accepted in a composite server-side natural key. */ export type OfflineNaturalKeyValue = string | number; @@ -22,15 +22,18 @@ const MAX_OFFLINE_NATURAL_KEY_TEXT_BYTES = 1024; /** Ordered natural-key values keyed by source-model field name. */ export type OfflineNaturalKey = Readonly>; +/** Server-assigned remote identifier for a generated-identity entity. */ +export type OfflineGeneratedRemoteId = number | string; + /** Remote row identity. Exactly one variant is valid for a replicated entity. */ export type OfflineReplicaRemoteIdentity = - | { readonly serverId: number; readonly naturalKey?: never } - | { readonly serverId?: never; readonly naturalKey: OfflineNaturalKey }; + | { readonly remoteId: OfflineGeneratedRemoteId; readonly naturalKey?: never } + | { readonly remoteId?: never; readonly naturalKey: OfflineNaturalKey }; /** Runtime identity policy materialized into an entity schema. */ export type OfflineReplicaIdentityDescriptor = | { readonly kind: 'localOnly'; readonly sourceKeys: readonly [] } - | { readonly kind: 'serverId'; readonly sourceKeys: readonly [string] } + | { readonly kind: 'generated'; readonly sourceKeys: readonly [string]; readonly affinity: OfflineReplicaSqliteAffinity } | { readonly kind: 'naturalKey'; readonly sourceKeys: readonly string[] }; /** Runtime descriptor for one mapped source property. */ @@ -91,10 +94,11 @@ export interface OfflineReplicaColumnDef< readonly __types?: { readonly value: TValue; readonly nullable: TNullable[typeof replicaNullableBrand] }; } -/** Builder marking a source property as the server-assigned identifier. */ -export interface OfflineReplicaServerIdDef { - readonly kind: 'serverId'; - readonly __types?: { readonly value: number }; +/** Builder marking a source property as the server-assigned generated identifier. */ +export interface OfflineReplicaRemoteIdDef { + readonly kind: 'remoteId'; + readonly affinity: TAffinity; + readonly __types?: TAffinity extends 'INTEGER' ? { readonly value: number } : { readonly value: string }; } /** Builder excluding a source property from SQLite projection. */ @@ -109,7 +113,12 @@ export interface OfflineReplicaNaturalKeyDef { readonly sourceKeys: readonly TKey[]; } -type OfflineReplicaFieldDef = OfflineReplicaColumnDef | OfflineReplicaServerIdDef | OfflineReplicaIgnoredDef; +/** Explicit identity for a projection that is never synchronized to a remote row. */ +export interface OfflineReplicaLocalOnlyDef { + readonly kind: 'localOnly'; +} + +type OfflineReplicaFieldDef = OfflineReplicaColumnDef | OfflineReplicaRemoteIdDef | OfflineReplicaIgnoredDef; type StripNullish = Exclude; @@ -138,8 +147,15 @@ type OfflineReplicaColumnDefForValue = OfflineReplicaColumnDef< ReplicaColumnNullabilityForSelect >; +type OfflineReplicaRemoteIdDefForValue = + StripNullish extends number + ? OfflineReplicaRemoteIdDef<'INTEGER'> + : StripNullish extends string + ? OfflineReplicaRemoteIdDef<'TEXT'> + : never; + type OfflineReplicaFieldDefForKey, K extends keyof TSelect> = - | (StripNullish extends number ? OfflineReplicaServerIdDef : never) + | OfflineReplicaRemoteIdDefForValue | OfflineReplicaIgnoredDef | OfflineReplicaColumnDefForValue; @@ -154,7 +170,7 @@ export interface OfflineReplicaEntityDefinition< readonly table: string; readonly sourceKey: string; readonly scope: OfflineReplicaEntityScope; - readonly identity?: OfflineReplicaNaturalKeyDef>; + readonly identity?: OfflineReplicaNaturalKeyDef> | OfflineReplicaLocalOnlyDef; readonly fields: ExactSelectKeys & TFields; } @@ -175,7 +191,7 @@ const RESERVED_COLUMN_NAMES = new Set([ * * Every key in `TSelect` must appear exactly once in `fields`, and column nullability * must match the select property nullability. An entity may map at most one field with - * {@link serverId}; omit it for local-only projections that have no remote row identity. + * {@link generatedId}; omit it for local-only projections that have no remote row identity. */ export function defineReplicaEntity>() { return function defineReplicaEntityConfig< @@ -241,21 +257,32 @@ export function nullable( }; } -/** Maps a source property to the shared nullable `server_id` column. */ -export function serverId(): OfflineReplicaServerIdDef { - return { kind: 'serverId' }; +/** Maps a source property to the nullable generated remote-id column. */ +export function generatedId(kind: 'integer'): OfflineReplicaRemoteIdDef<'INTEGER'>; +export function generatedId(kind: 'text'): OfflineReplicaRemoteIdDef<'TEXT'>; +export function generatedId(kind: 'integer' | 'text'): OfflineReplicaRemoteIdDef { + return { kind: 'remoteId', affinity: kind === 'integer' ? 'INTEGER' : 'TEXT' }; } -/** Declares an ordered composite natural key without changing the immutable local UUID. */ +/** Declares an ordered remote natural key used directly as the scoped SQLite primary key. */ export function naturalKey(sourceKeys: readonly TKey[]): OfflineReplicaNaturalKeyDef { return { kind: 'naturalKey', sourceKeys }; } -/** Rejects a remote identity on a local-only projection before it reaches platform storage. */ -export function assertOfflineReplicaServerId(schema: OfflineReplicaEntitySchema>, value: number | null): void { - if (schema.identity.kind !== 'serverId' && value !== null) { - throw new Error(`Offline replica source "${schema.sourceKey}" does not define a serverId field.`); +/** Declares a projection with a local UUID address and no Outbox identity. */ +export function localOnly(): OfflineReplicaLocalOnlyDef { + return { kind: 'localOnly' }; +} +/** Rejects a generated remote id on a local-only or natural-key projection before it reaches platform storage. */ +export function assertOfflineReplicaGeneratedRemoteId( + schema: OfflineReplicaEntitySchema>, + value: OfflineGeneratedRemoteId | null, +): void { + if (schema.identity.kind !== 'generated' && value !== null) { + throw new Error(`Offline replica source "${schema.sourceKey}" does not define a generated remote id field.`); } + if (value === null) return; + assertValidGeneratedRemoteId(schema, value); } /** Extracts and validates an entity's natural key in declared component order. */ @@ -309,15 +336,15 @@ export function canonicalOfflineRemoteIdentity( schema: OfflineReplicaEntitySchema>, identity: OfflineReplicaRemoteIdentity, ): string { - if (schema.identity.kind === 'serverId') { - if (!('serverId' in identity) || 'naturalKey' in identity) { - throw new Error(`Offline replica source "${schema.sourceKey}" requires serverId identity.`); + if (schema.identity.kind === 'generated') { + if (!('remoteId' in identity) || 'naturalKey' in identity) { + throw new Error(`Offline replica source "${schema.sourceKey}" requires generated remote id identity.`); } - assertPositiveServerId(identity.serverId); - return `serverId:n:${identity.serverId}`; + assertValidGeneratedRemoteId(schema, identity.remoteId); + return typeof identity.remoteId === 'string' ? `remoteId:s:${identity.remoteId}` : `remoteId:n:${identity.remoteId}`; } if (schema.identity.kind === 'naturalKey') { - if (!('naturalKey' in identity) || 'serverId' in identity) { + if (!('naturalKey' in identity) || 'remoteId' in identity) { throw new Error(`Offline replica source "${schema.sourceKey}" requires naturalKey identity.`); } const key = normalizeOfflineNaturalKey(schema, identity.naturalKey); @@ -347,10 +374,20 @@ export function assertOfflineReplicaNaturalKeyBaseline( } } -function assertPositiveServerId(value: number): void { - if (!Number.isSafeInteger(value) || value <= 0) { - throw new Error('Offline replica serverId must be a positive integer.'); +function assertValidGeneratedRemoteId(schema: OfflineReplicaEntitySchema>, value: OfflineGeneratedRemoteId): void { + if (schema.identity.kind !== 'generated') { + throw new Error(`Offline replica source "${schema.sourceKey}" does not define a generated remote id field.`); } + if (schema.identity.affinity === 'INTEGER') { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) { + throw new Error('Offline replica generated remote id must be a positive integer.'); + } + return; + } + if (typeof value !== 'string' || value.length === 0) { + throw new Error('Offline replica generated remote id must be a non-empty string.'); + } + assertNaturalKeyText('remoteId', value); } function assertNaturalKeyText(sourceKey: string, value: string): void { @@ -374,15 +411,15 @@ function buildOfflineReplicaEntitySchema readonly table: string; readonly sourceKey: string; readonly scope: OfflineReplicaEntityScope; - readonly identity?: OfflineReplicaNaturalKeyDef; + readonly identity?: OfflineReplicaNaturalKeyDef | OfflineReplicaLocalOnlyDef; readonly fields: Record; }): OfflineReplicaEntitySchema { validateIdentifier(definition.table, 'table'); validateIdentifier(definition.sourceKey, 'source key'); const sourceKeys = Object.keys(definition.fields).sort(); - const serverIdCount = sourceKeys.filter((sourceKey) => definition.fields[sourceKey]?.kind === 'serverId').length; - if (serverIdCount > 1) throw new Error('Replica entity must define at most one serverId field.'); + const remoteIdCount = sourceKeys.filter((sourceKey) => definition.fields[sourceKey]?.kind === 'remoteId').length; + if (remoteIdCount > 1) throw new Error('Replica entity must define at most one remoteId field.'); const fields: OfflineReplicaFieldDescriptor[] = sourceKeys.map((sourceKey) => { const fieldDef = definition.fields[sourceKey as keyof TSelect] as OfflineReplicaFieldDef; return materializeFieldDescriptor(sourceKey, fieldDef); @@ -404,13 +441,14 @@ function buildOfflineReplicaEntitySchema } function materializeIdentity( - definition: OfflineReplicaNaturalKeyDef | undefined, + definition: OfflineReplicaNaturalKeyDef | OfflineReplicaLocalOnlyDef | undefined, fields: readonly OfflineReplicaFieldDescriptor[], ): OfflineReplicaIdentityDescriptor { - const serverField = fields.find((field) => field.policy === 'serverId'); - if (definition && serverField) throw new Error('Replica entity cannot define both serverId and naturalKey identity.'); - if (serverField) return { kind: 'serverId', sourceKeys: [serverField.sourceKey] }; - if (!definition) return { kind: 'localOnly', sourceKeys: [] }; + const serverField = fields.find((field) => field.policy === 'remoteId'); + if (definition && serverField) throw new Error('Replica entity cannot combine a generated id field with another identity.'); + if (serverField) return { kind: 'generated', sourceKeys: [serverField.sourceKey], affinity: serverField.affinity! }; + if (!definition) throw new Error('Replica entity without a generated id field must declare identity explicitly.'); + if (definition.kind === 'localOnly') return { kind: 'localOnly', sourceKeys: [] }; if (definition.sourceKeys.length === 0) throw new Error('Replica naturalKey must contain at least one source field.'); if (new Set(definition.sourceKeys).size !== definition.sourceKeys.length) { throw new Error('Replica naturalKey source fields must be unique.'); @@ -439,12 +477,12 @@ function materializeFieldDescriptor(sourceKey: string, fieldDef: OfflineReplicaF }; } - if (fieldDef.kind === 'serverId') { + if (fieldDef.kind === 'remoteId') { return { sourceKey, - policy: 'serverId', + policy: 'remoteId', sqliteColumnName: 'server_id', - affinity: 'INTEGER', + affinity: fieldDef.affinity, storageKind: null, nullable: true, ignoredReason: null, @@ -474,17 +512,20 @@ function buildCreateTableSql( fields: readonly OfflineReplicaFieldDescriptor[], identity: OfflineReplicaIdentityDescriptor, ): readonly string[] { - const columnLines = ['local_id TEXT NOT NULL', '_offline_user_id INTEGER NOT NULL']; + const columnLines = ['_offline_user_id TEXT NOT NULL']; if (scope === 'partition') { columnLines.push('_offline_scope_id TEXT NOT NULL'); } - if (identity.kind === 'serverId') { - columnLines.push('server_id INTEGER'); + if (identity.kind === 'generated') { + columnLines.push('local_id TEXT NOT NULL', `server_id ${identity.affinity}`); + } else if (identity.kind === 'localOnly') { + columnLines.push('local_id TEXT NOT NULL'); } columnLines.push( '_offline_confirmed_json TEXT', '_offline_server_revision_json TEXT', '_offline_sync_state TEXT NOT NULL', + "_offline_visibility TEXT NOT NULL DEFAULT 'present'", '_offline_fetched_at INTEGER NOT NULL', ); @@ -496,19 +537,23 @@ function buildCreateTableSql( columnLines.push(`${field.sqliteColumnName} ${field.affinity}${nullability}`); } - const statements = [`CREATE TABLE IF NOT EXISTS ${tableName} (\n ${columnLines.join(',\n ')},\n PRIMARY KEY (local_id)\n)`]; + const pkColumns = [ + '_offline_user_id', + ...(scope === 'partition' ? ['_offline_scope_id'] : []), + ...(identity.kind === 'naturalKey' + ? identity.sourceKeys.map((sourceKey) => fields.find((field) => field.sourceKey === sourceKey)!.sqliteColumnName!) + : ['local_id']), + ]; + + const statements = [ + `CREATE TABLE IF NOT EXISTS ${tableName} (\n ${columnLines.join(',\n ')},\n PRIMARY KEY (${pkColumns.join(', ')})\n)`, + ]; - if (identity.kind === 'serverId') { + if (identity.kind === 'generated') { const indexColumns = scope === 'partition' ? '_offline_user_id, _offline_scope_id, server_id' : '_offline_user_id, server_id'; statements.push( `CREATE UNIQUE INDEX IF NOT EXISTS uq_${tableName}_server_id ON ${tableName} (${indexColumns}) WHERE server_id IS NOT NULL`, ); - } else if (identity.kind === 'naturalKey') { - const naturalKeyColumns = identity.sourceKeys.map( - (sourceKey) => fields.find((field) => field.sourceKey === sourceKey)!.sqliteColumnName!, - ); - const indexColumns = ['_offline_user_id', ...(scope === 'partition' ? ['_offline_scope_id'] : []), ...naturalKeyColumns].join(', '); - statements.push(`CREATE UNIQUE INDEX IF NOT EXISTS uq_${tableName}_natural_key ON ${tableName} (${indexColumns})`); } return statements; @@ -525,8 +570,8 @@ function buildSchemaFingerprintInput( if (field.policy === 'ignored') { return `${field.sourceKey}:ignored:${field.ignoredReason ?? ''}`; } - if (field.policy === 'serverId') { - return `${field.sourceKey}:serverId:server_id:INTEGER:nullable`; + if (field.policy === 'remoteId') { + return `${field.sourceKey}:remoteId:server_id:${field.affinity}:nullable`; } return `${field.sourceKey}:column:${field.sqliteColumnName}:${field.affinity}:${field.storageKind}:${field.nullable ? 'nullable' : 'required'}`; }); @@ -536,8 +581,10 @@ function buildSchemaFingerprintInput( `source=${sourceKey}`, `scope=${scope}`, ...(identity.kind === 'naturalKey' - ? [`hasServerId=0`, `identity=naturalKey:${identity.sourceKeys.join(',')}`, 'identityCodec=v1'] - : [`hasServerId=${identity.kind === 'serverId' ? '1' : '0'}`]), + ? [`identity=naturalKey:${identity.sourceKeys.join(',')}`, 'identityCodec=v2'] + : identity.kind === 'generated' + ? [`identity=generated:${identity.sourceKeys[0]}:${identity.affinity}`, 'identityCodec=v2'] + : ['identity=localOnly', 'identityCodec=v2']), `fields=${fieldParts.join(';')}`, ].join('|'); } @@ -560,7 +607,7 @@ function toSnakeCase(value: string): string { * * Only {@link OfflineReplicaFieldPolicy} `column` fields are emitted, keyed by * {@link OfflineReplicaFieldDescriptor.sqliteColumnName} in descriptor order. - * Source properties mapped as `serverId` or `ignored` may be present but are not encoded. + * Source properties mapped as `remoteId` or `ignored` may be present but are not encoded. */ export function encodeOfflineReplicaValues( schema: OfflineReplicaEntitySchema>, @@ -668,8 +715,8 @@ export function decodeOfflineReplicaValues( /** * Validates a source-model row and returns the canonical domain projection persisted by every repository. * - * Fields mapped as `serverId` or `ignored` are intentionally omitted. This keeps web and native row shapes - * identical; the server identifier remains available through the replica row's dedicated `serverId` field. + * Fields mapped as `remoteId` or `ignored` are intentionally omitted. This keeps web and native row shapes + * identical; the server identifier remains available through the replica row's dedicated `remoteId` field. */ export function projectOfflineReplicaValues( schema: OfflineReplicaEntitySchema>, @@ -875,7 +922,7 @@ export interface OfflineReplicaMigration { /** * Transforms one row's domain projection for web storage during this step. * The callback receives only {@link OfflineReplicaWebMigrationRow}; it must not read or mutate - * `localId`, `serverId`, scope, revision, or `syncState`. Return `null` to delete the row. + * `localId`, `remoteId`, scope, revision, or `syncState`. Return `null` to delete the row. */ readonly migrateWebRow: OfflineReplicaWebMigrationCallback; } diff --git a/projects/kit/offline/src/lib/offline-repository.spec.ts b/projects/kit/offline/src/lib/offline-repository.spec.ts index 85d9f3b..6604691 100644 --- a/projects/kit/offline/src/lib/offline-repository.spec.ts +++ b/projects/kit/offline/src/lib/offline-repository.spec.ts @@ -8,13 +8,15 @@ import { defineOfflineReplicaSchema, defineReplicaEntity, integer, + localOnly, naturalKey, - serverId, + generatedId, sha256OfflineReplicaSchema, text, type OfflineReplicaSchemaBundle, } from './offline-replica-schema'; import { + canonicalOfflineReplicaIdentity, IonicOfflineRepository, OFFLINE_REPOSITORY, OFFLINE_SCHEMA_VERSION, @@ -23,17 +25,19 @@ import { type OfflineReplicaRow, type OfflineRepository, } from './offline-repository'; +import { generatedCommandIdentity, generatedReplicaIdentity, naturalCommandIdentity, naturalReplicaIdentity } from './offline-test-helpers'; type TestItemSelect = { id: number; title: string }; type TestItemWithSubtitleSelect = { id: number; title: string; subtitle: string }; type LocalProjectionSelect = { feedKey: string }; +type TextIdSelect = { id: string; title: string }; const testItemEntity = defineReplicaEntity()({ table: 'test_items', sourceKey: 'test_items', scope: 'user', fields: { - id: serverId(), + id: generatedId('integer'), title: text(), }, }); @@ -43,7 +47,7 @@ const testItemWithSubtitleEntity = defineReplicaEntity()( sourceKey: 'test_group_items', scope: 'partition', fields: { - id: serverId(), + id: generatedId('integer'), name: text(), }, }); @@ -63,6 +67,7 @@ const localProjectionEntity = defineReplicaEntity()({ table: 'local_projections', sourceKey: 'local_projections', scope: 'user', + identity: localOnly(), fields: { feedKey: text(), }, @@ -76,6 +81,19 @@ const naturalFavoriteEntity = defineReplicaEntity<{ favFrom: number; favTo: stri fields: { favFrom: integer(), favTo: text(), label: text() }, }); +const textIdEntity = defineReplicaEntity()({ + table: 'text_id_items', + sourceKey: 'text_id_items', + scope: 'user', + fields: { id: generatedId('text'), title: text() }, +}); + +const textIdSchema = defineOfflineReplicaSchema({ + version: 1, + entities: [textIdEntity], + migrations: [], +}); + const naturalFavoriteSchema = defineOfflineReplicaSchema({ version: 1, entities: [naturalFavoriteEntity], @@ -152,7 +170,7 @@ const replicaSchemaV2Rekey = defineOfflineReplicaSchema({ sourceKey: 'renamed_items', scope: 'user', fields: { - id: serverId(), + id: generatedId('integer'), title: text(), }, }), @@ -179,7 +197,7 @@ const replicaSchemaV2RekeyCollision = defineOfflineReplicaSchema({ sourceKey: 'renamed_items', scope: 'user', fields: { - id: serverId(), + id: generatedId('integer'), title: text(), }, }), @@ -277,8 +295,7 @@ describe('IonicOfflineRepository', () => { userId: 1, scopeId: '10', sourceKey: 'test_items', - localId: '019d-aaaa', - serverId: 42, + identity: { kind: 'generated', localId: '019d-aaaa', remoteId: 42 }, values: { id: 42, title: 'Local item' }, confirmedValues: { id: 42, title: 'Confirmed item' }, serverRevision: 7, @@ -292,7 +309,8 @@ describe('IonicOfflineRepository', () => { scopeId: '10', commandId: 'update-1', aggregateType: 'test_items', - aggregateLocalId: '019d-aaaa', + sourceKey: 'test_items', + identity: { kind: 'generated', localId: '019d-aaaa' }, operation: 'test_items.update', payload: { title: 'Local item' }, optimisticValue: { id: 42, title: 'Local item' }, @@ -309,9 +327,10 @@ describe('IonicOfflineRepository', () => { }); await repository.initialize(); - await expect(repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'test_items', '019d-aaaa')).resolves.toMatchObject({ - localId: '019d-aaaa', - serverId: 42, + await expect( + repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'test_items', generatedCommandIdentity('019d-aaaa')), + ).resolves.toMatchObject({ + identity: { kind: 'generated', localId: '019d-aaaa', remoteId: 42 }, serverRevision: 7, fetchedAt: 99, syncState: 'confirmed', @@ -329,13 +348,13 @@ describe('IonicOfflineRepository', () => { it('delete transformで行だけ削除しoutboxは保持する', async () => { const keepRow: OfflineReplicaRow = { ...baseRow, - localId: '019d-bbbb', + identity: { kind: 'generated', localId: '019d-bbbb', remoteId: 43 }, values: { id: 43, title: 'Keep me' }, confirmedValues: null, }; const dropRow: OfflineReplicaRow = { ...baseRow, - localId: '019d-cccc', + identity: { kind: 'generated', localId: '019d-cccc', remoteId: 44 }, values: { id: 44, title: 'drop-me' }, confirmedValues: null, }; @@ -344,7 +363,8 @@ describe('IonicOfflineRepository', () => { scopeId: '10', commandId: 'delete-1', aggregateType: 'test_items', - aggregateLocalId: '019d-cccc', + sourceKey: 'test_items', + identity: { kind: 'generated', localId: '019d-cccc' }, operation: 'test_items.delete', payload: {}, optimisticValue: {}, @@ -368,8 +388,10 @@ describe('IonicOfflineRepository', () => { }); await repository.initialize(); - expect(await repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'test_items', '019d-cccc')).toBeNull(); - await expect(repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'test_items', '019d-bbbb')).resolves.toMatchObject({ + expect(await repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'test_items', generatedCommandIdentity('019d-cccc'))).toBeNull(); + await expect( + repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'test_items', generatedCommandIdentity('019d-bbbb')), + ).resolves.toMatchObject({ values: { title: 'Keep me', subtitle: 'kept' }, }); expect(await repository.getCommands({ userId: 1, scopeId: '10' })).toHaveLength(1); @@ -382,7 +404,8 @@ describe('IonicOfflineRepository', () => { scopeId: '10', commandId: 'update-1', aggregateType: 'test_items', - aggregateLocalId: '019d-aaaa', + sourceKey: 'test_items', + identity: { kind: 'generated', localId: '019d-aaaa' }, operation: 'test_items.update', payload: {}, optimisticValue: {}, @@ -426,8 +449,7 @@ describe('IonicOfflineRepository', () => { userId: 1, scopeId: '10', sourceKey: 'test_items', - localId: '019d-new', - serverId: null, + identity: { kind: 'generated', localId: '019d-new', remoteId: null }, values: { id: 0, title: 'New', subtitle: 'added' }, confirmedValues: null, serverRevision: null, @@ -445,7 +467,8 @@ describe('IonicOfflineRepository', () => { scopeId: '10', commandId: 'update-1', aggregateType: 'test_items', - aggregateLocalId: '019d-aaaa', + sourceKey: 'test_items', + identity: { kind: 'generated', localId: '019d-aaaa' }, operation: 'test_items.update', payload: { title: 'Local item' }, optimisticValue: { id: 42, title: 'Local item' }, @@ -502,7 +525,9 @@ describe('IonicOfflineRepository', () => { }); await repository.initialize(); - await expect(repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'test_items', '019d-aaaa')).resolves.toMatchObject({ + await expect( + repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'test_items', generatedCommandIdentity('019d-aaaa')), + ).resolves.toMatchObject({ values: { title: 'Local item', subtitle: 'migrated' }, }); expect(storage.values.get('offline:replica:schema-migration')).toBeUndefined(); @@ -553,7 +578,9 @@ describe('IonicOfflineRepository', () => { repository = createRepository(replicaSchemaV2, { preserveStorage: true }); await repository.initialize(); - await expect(repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'test_items', '019d-aaaa')).resolves.toMatchObject({ + await expect( + repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'test_items', generatedCommandIdentity('019d-aaaa')), + ).resolves.toMatchObject({ values: { title: 'Local item', subtitle: 'migrated' }, }); expect(storage.values.get('offline:metadata')).toMatchObject({ replicaSchemaVersion: 2 }); @@ -561,8 +588,16 @@ describe('IonicOfflineRepository', () => { }); it('sourceKey re-key collisionはmigrationを拒否する', async () => { - const rowA: OfflineReplicaRow = { ...baseRow, localId: '019d-aaaa', serverId: 42, values: { id: 42, title: 'A' } }; - const rowB: OfflineReplicaRow = { ...baseRow, localId: '019d-aaaa', serverId: 43, values: { id: 43, title: 'B' } }; + const rowA: OfflineReplicaRow = { + ...baseRow, + identity: { kind: 'generated', localId: '019d-aaaa', remoteId: 42 }, + values: { id: 42, title: 'A' }, + }; + const rowB: OfflineReplicaRow = { + ...baseRow, + identity: { kind: 'generated', localId: '019d-aaaa', remoteId: 43 }, + values: { id: 43, title: 'B' }, + }; repository = await createSeededRepository(replicaSchemaV2RekeyCollision, async () => { await seedReplicaMetadata(replicaSchemaV1, { '1:10:test_items:019d-aaaa': rowA, @@ -580,10 +615,12 @@ describe('IonicOfflineRepository', () => { }); await repository.initialize(); - await expect(repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'test_items', '019d-aaaa')).rejects.toThrow( - 'Unknown offline replica source key "test_items".', - ); - await expect(repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'renamed_items', '019d-aaaa')).resolves.toMatchObject({ + await expect( + repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'test_items', generatedCommandIdentity('019d-aaaa')), + ).rejects.toThrow('Unknown offline replica source key "test_items".'); + await expect( + repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'renamed_items', generatedCommandIdentity('019d-aaaa')), + ).resolves.toMatchObject({ sourceKey: 'renamed_items', values: { title: 'Local item' }, }); @@ -608,7 +645,9 @@ describe('IonicOfflineRepository', () => { }); await repository.initialize(); - await expect(repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'test_items', '019d-aaaa')).resolves.toMatchObject({ + await expect( + repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'test_items', generatedCommandIdentity('019d-aaaa')), + ).resolves.toMatchObject({ values: { title: 'Local item', subtitle: 'migrated' }, }); expect(storage.values.get('offline:metadata')).toMatchObject({ @@ -649,8 +688,7 @@ describe('IonicOfflineRepository', () => { userId: 1, scopeId: '10', sourceKey: 'test_items', - localId: '019d-user', - serverId: 42, + identity: { kind: 'generated', localId: '019d-user', remoteId: 42 }, values: { id: 42, title: 'User scoped' }, confirmedValues: { id: 42, title: 'User scoped' }, serverRevision: 1, @@ -661,8 +699,7 @@ describe('IonicOfflineRepository', () => { userId: 1, scopeId: '10', sourceKey: 'test_group_items', - localId: '019d-group', - serverId: 55, + identity: { kind: 'generated', localId: '019d-group', remoteId: 55 }, values: { id: 55, name: 'Partition scoped' }, confirmedValues: { id: 55, name: 'Partition scoped' }, serverRevision: 1, @@ -674,10 +711,14 @@ describe('IonicOfflineRepository', () => { await repository.clearScope({ userId: 1, scopeId: '10' }); - await expect(repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'test_items', '019d-user')).resolves.toMatchObject({ - localId: '019d-user', + await expect( + repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'test_items', generatedCommandIdentity('019d-user')), + ).resolves.toMatchObject({ + identity: { kind: 'generated', localId: '019d-user', remoteId: 42 }, }); - expect(await repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'test_group_items', '019d-group')).toBeNull(); + expect( + await repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'test_group_items', generatedCommandIdentity('019d-group')), + ).toBeNull(); }); describe('user-scope cross-partition parity', () => { @@ -685,40 +726,50 @@ describe('IonicOfflineRepository', () => { const scopeG11 = { userId: 1, scopeId: '11' }; const userRow = { sourceKey: 'test_items', - localId: '019d-cross', - serverId: 42, + identity: generatedReplicaIdentity('019d-cross', 42), confirmedValues: { id: 42, title: 'Shared user row' }, serverRevision: 1, fetchedAt: 1, syncState: 'confirmed' as const, }; - it('同一localIdのserverId再割当をdirect transactionでもrejectする', async () => { + it('同一localIdのremoteId再割当をdirect transactionでもrejectする', async () => { await expect( repository.transactReplica({ - putRows: [{ ...userRow, ...scopeG10, localId: '019d-cross', serverId: 43, values: { id: 43, title: 'B' } }], + putRows: [{ ...userRow, ...scopeG10, identity: generatedReplicaIdentity('019d-cross', 43), values: { id: 43, title: 'B' } }], }), - ).rejects.toThrow('Offline replica serverId is immutable: current=42, incoming=43.'); + ).rejects.toThrow('Offline replica remoteId is immutable: current=42, incoming=43.'); }); - it('明示したidentity releaseだけがserverIdをnullへ戻して後続createの再割当を許可する', async () => { + it('明示したidentity releaseだけがremoteIdをnullへ戻して後続createの再割当を許可する', async () => { const released = { ...userRow, ...scopeG10, - serverId: null, + identity: generatedReplicaIdentity('019d-cross', null), values: { id: 42, title: 'Recreate pending' }, confirmedValues: null, syncState: 'pending' as const, }; await repository.transactReplica({ putRows: [released], - releaseServerIds: [{ ...scopeG10, sourceKey: 'test_items', localId: '019d-cross', serverId: 42 }], + releaseRemoteIds: [{ ...scopeG10, sourceKey: 'test_items', identity: generatedReplicaIdentity('019d-cross', 42), remoteId: 42 }], + }); + await expect(repository.getReplicaRow(scopeG10, 'test_items', generatedCommandIdentity('019d-cross'))).resolves.toMatchObject({ + identity: { kind: 'generated', localId: '019d-cross', remoteId: null }, }); - await expect(repository.getReplicaRow(scopeG10, 'test_items', '019d-cross')).resolves.toMatchObject({ serverId: null }); await repository.transactReplica({ - putRows: [{ ...released, serverId: 43, values: { id: 43, title: 'Recreated' }, syncState: 'confirmed' }], + putRows: [ + { + ...released, + identity: generatedReplicaIdentity('019d-cross', 43), + values: { id: 43, title: 'Recreated' }, + syncState: 'confirmed', + }, + ], + }); + await expect(repository.getReplicaRow(scopeG11, 'test_items', generatedCommandIdentity('019d-cross'))).resolves.toMatchObject({ + identity: { kind: 'generated', localId: '019d-cross', remoteId: 43 }, }); - await expect(repository.getReplicaRow(scopeG11, 'test_items', '019d-cross')).resolves.toMatchObject({ serverId: 43 }); }); beforeEach(async () => { @@ -735,16 +786,16 @@ describe('IonicOfflineRepository', () => { }); it('getReplicaRowは別scopeIdでも同一user rowを返す', async () => { - await expect(repository.getReplicaRow(scopeG11, 'test_items', '019d-cross')).resolves.toMatchObject({ - localId: '019d-cross', + await expect(repository.getReplicaRow(scopeG11, 'test_items', generatedCommandIdentity('019d-cross'))).resolves.toMatchObject({ + identity: { kind: 'generated', localId: '019d-cross' }, scopeId: '11', values: { title: 'Shared user row' }, }); }); - it('getReplicaRowByServerIdは別scopeIdでも同一user rowを返す', async () => { - await expect(repository.getReplicaRowByServerId(scopeG11, 'test_items', 42)).resolves.toMatchObject({ - localId: '019d-cross', + it('getReplicaRowByRemoteIdは別scopeIdでも同一user rowを返す', async () => { + await expect(repository.getReplicaRowByRemoteId(scopeG11, 'test_items', 42)).resolves.toMatchObject({ + identity: { kind: 'generated', localId: '019d-cross', remoteId: 42 }, }); }); @@ -761,7 +812,7 @@ describe('IonicOfflineRepository', () => { ], }); - await expect(repository.getReplicaRow(scopeG10, 'test_items', '019d-cross')).resolves.toMatchObject({ + await expect(repository.getReplicaRow(scopeG10, 'test_items', generatedCommandIdentity('019d-cross'))).resolves.toMatchObject({ values: { title: 'Updated from G11' }, scopeId: '10', }); @@ -772,7 +823,8 @@ describe('IonicOfflineRepository', () => { const base: Omit = { userId: 1, aggregateType: 'test_items', - aggregateLocalId: '019d-aaaa', + sourceKey: 'test_items', + identity: { kind: 'generated', localId: '019d-aaaa' }, operation: 'test_items.update', payload: {}, optimisticValue: {}, @@ -790,11 +842,12 @@ describe('IonicOfflineRepository', () => { expect((await repository.getCommandsForUser!(1)).map((item) => item.commandId)).toEqual(['cmd-a', 'cmd-m', 'cmd-z']); }); - it('outboxを作成順で保持し、partition削除時はそのscopeだけを消す', async () => { + it('outboxを作成順で保持し、scope削除時もuser-scoped commandを保持する', async () => { const base: Omit = { userId: 1, - aggregateType: 'documents', - aggregateLocalId: '019d-aaaa', + aggregateType: 'test_items', + sourceKey: 'test_items', + identity: { kind: 'generated', localId: '019d-aaaa' }, operation: 'documents.upsert', payload: {}, optimisticValue: {}, @@ -811,7 +864,7 @@ describe('IonicOfflineRepository', () => { expect((await repository.getCommands({ userId: 1, scopeId: '10' })).map((item) => item.commandId)).toEqual(['earlier', 'later']); await repository.clearScope({ userId: 1, scopeId: '10' }); - expect(await repository.getCommands({ userId: 1, scopeId: '10' })).toEqual([]); + expect(await repository.getCommands({ userId: 1, scopeId: '10' })).toHaveLength(2); expect(await repository.getCommands({ userId: 1, scopeId: '11' })).toHaveLength(1); }); @@ -837,8 +890,7 @@ describe('IonicOfflineRepository', () => { const row = { ...scope, sourceKey: 'test_items', - localId: '019d-aaaa', - serverId: null, + identity: { kind: 'generated', localId: '019d-aaaa', remoteId: null }, values: { id: 0, title: 'local' }, confirmedValues: null, serverRevision: null, @@ -850,7 +902,8 @@ describe('IonicOfflineRepository', () => { scopeId: '10', commandId: 'create-1', aggregateType: 'test_items', - aggregateLocalId: '019d-aaaa', + sourceKey: 'test_items', + identity: { kind: 'generated', localId: '019d-aaaa' }, operation: 'test_items.create', payload: { title: 'local' }, optimisticValue: { id: 0, title: 'local' }, @@ -863,24 +916,24 @@ describe('IonicOfflineRepository', () => { lastErrorCode: null, }; await repository.transactReplica({ putRows: [row], putCommands: [command] }); - await expect(repository.getReplicaRow(scope, 'test_items', '019d-aaaa')).resolves.toMatchObject({ - localId: '019d-aaaa', - serverId: null, + await expect(repository.getReplicaRow(scope, 'test_items', generatedCommandIdentity('019d-aaaa'))).resolves.toMatchObject({ + identity: { kind: 'generated', localId: '019d-aaaa', remoteId: null }, }); expect(await repository.getCommands(scope)).toHaveLength(1); await repository.transactReplica({ - putRows: [{ ...row, serverId: 38142, values: { id: 38142, title: 'local' }, syncState: 'confirmed' }], + putRows: [ + { ...row, identity: generatedReplicaIdentity('019d-aaaa', 38142), values: { id: 38142, title: 'local' }, syncState: 'confirmed' }, + ], removeCommandIds: ['create-1'], }); - await expect(repository.getReplicaRow(scope, 'test_items', '019d-aaaa')).resolves.toMatchObject({ - localId: '019d-aaaa', - serverId: 38142, + await expect(repository.getReplicaRow(scope, 'test_items', generatedCommandIdentity('019d-aaaa'))).resolves.toMatchObject({ + identity: { kind: 'generated', localId: '019d-aaaa', remoteId: 38142 }, }); expect(await repository.getCommands(scope)).toEqual([]); }); - it('local-only projectionをserverIdなしでround-tripしserverId lookupは常にnullを返す', async () => { + it('local-only projectionをremoteIdなしでround-tripしremoteId lookupは常にnullを返す', async () => { repository = createRepository(localProjectionSchema); await repository.initialize(); const scope = { userId: 1, scopeId: '10' }; @@ -889,8 +942,7 @@ describe('IonicOfflineRepository', () => { { ...scope, sourceKey: 'local_projections', - localId: 'feed-home', - serverId: null, + identity: { kind: 'local', localId: 'feed-home' }, values: { feedKey: 'home' }, confirmedValues: { feedKey: 'home' }, serverRevision: null, @@ -902,15 +954,14 @@ describe('IonicOfflineRepository', () => { await expect(repository.getReplicaRows(scope, 'local_projections')).resolves.toEqual([ expect.objectContaining({ - localId: 'feed-home', - serverId: null, + identity: { kind: 'local', localId: 'feed-home' }, values: { feedKey: 'home' }, }), ]); - await expect(repository.getReplicaRowByServerId(scope, 'local_projections', 1)).resolves.toBeNull(); + await expect(repository.getReplicaRowByRemoteId(scope, 'local_projections', 1)).resolves.toBeNull(); }); - it('local-only projectionへ非null serverIdを渡すと永続化前にrejectする', async () => { + it('local-only projectionへgenerated identityを渡すと永続化前にrejectする', async () => { repository = createRepository(localProjectionSchema); await repository.initialize(); await expect( @@ -920,8 +971,7 @@ describe('IonicOfflineRepository', () => { userId: 1, scopeId: '10', sourceKey: 'local_projections', - localId: 'feed-home', - serverId: 1, + identity: { kind: 'generated', localId: 'feed-home', remoteId: 1 }, values: { feedKey: 'home' }, confirmedValues: null, serverRevision: null, @@ -930,7 +980,7 @@ describe('IonicOfflineRepository', () => { }, ], }), - ).rejects.toThrow('Offline replica source "local_projections" does not define a serverId field.'); + ).rejects.toThrow('Offline replica source "local_projections" requires local identity.'); await expect(repository.getReplicaRows({ userId: 1, scopeId: '10' }, 'local_projections')).resolves.toEqual([]); }); @@ -938,7 +988,7 @@ describe('IonicOfflineRepository', () => { storage.values.set('offline:metadata', { schemaVersion: 999, lastUserId: 1 }); storage.values.set('offline:outbox:commands', { stale: {} }); storage.values.set('firebaseToken', { token: 'keep' }); - await expect(repository.initialize()).rejects.toThrow('Unsupported offline storage schema version 999; expected 5'); + await expect(repository.initialize()).rejects.toThrow('Unsupported offline storage schema version 999; expected 1'); expect(storage.values.get('offline:outbox:commands')).toEqual({ stale: {} }); expect(storage.values.get('offline:metadata')).toEqual({ schemaVersion: 999, lastUserId: 1 }); expect(storage.values.get('firebaseToken')).toEqual({ token: 'keep' }); @@ -955,8 +1005,7 @@ describe('IonicOfflineRepository', () => { const row = { ...scope, sourceKey: 'test_items', - localId: '019d-bbbb', - serverId: null, + identity: { kind: 'generated', localId: '019d-bbbb', remoteId: null }, values: { id: 0, title: 'Local item' }, confirmedValues: null, serverRevision: null, @@ -967,7 +1016,8 @@ describe('IonicOfflineRepository', () => { ...scope, commandId: 'create-row-1', aggregateType: 'test_items', - aggregateLocalId: '019d-bbbb', + sourceKey: 'test_items', + identity: { kind: 'generated', localId: '019d-bbbb' }, operation: 'test_items.create', payload: { title: 'Local item' }, optimisticValue: { id: 0, title: 'Local item' }, @@ -980,8 +1030,8 @@ describe('IonicOfflineRepository', () => { lastErrorCode: null, }; await repository.transactReplica({ putRows: [row], putCommands: [command] }); - await expect(repository.getReplicaRow(scope, 'test_items', '019d-bbbb')).resolves.toMatchObject({ - localId: '019d-bbbb', + await expect(repository.getReplicaRow(scope, 'test_items', generatedCommandIdentity('019d-bbbb'))).resolves.toMatchObject({ + identity: { kind: 'generated', localId: '019d-bbbb', remoteId: null }, values: { title: 'Local item' }, }); expect(await repository.getCommands(scope)).toHaveLength(1); @@ -992,8 +1042,7 @@ describe('IonicOfflineRepository', () => { const row: OfflineReplicaRow = { ...scope, sourceKey: 'test_items', - localId: 'delete-uuid', - serverId: 42, + identity: { kind: 'generated', localId: 'delete-uuid', remoteId: 42 }, values: { id: 42, title: 'visible before delete' }, confirmedValues: { id: 42, title: 'confirmed baseline' }, serverRevision: 7, @@ -1005,7 +1054,8 @@ describe('IonicOfflineRepository', () => { ...scope, commandId: 'delete-command', aggregateType: 'test_items', - aggregateLocalId: row.localId, + sourceKey: 'test_items', + identity: generatedCommandIdentity('delete-uuid'), operation: 'test_items.delete', payload: { id: 42 }, optimisticValue: row.values, @@ -1021,17 +1071,18 @@ describe('IonicOfflineRepository', () => { await repository.transactReplica({ putRows: [row], putCommands: [command] }); - await expect(repository.getReplicaRow(scope, 'test_items', row.localId)).resolves.toBeNull(); + await expect(repository.getReplicaRow(scope, 'test_items', generatedCommandIdentity('delete-uuid'))).resolves.toBeNull(); await expect(repository.getReplicaRows(scope, 'test_items')).resolves.toEqual([]); - await expect(repository.getReplicaRowIncludingPendingDelete?.(scope, 'test_items', row.localId)).resolves.toMatchObject({ - localId: row.localId, - serverId: 42, + await expect( + repository.getReplicaRowIncludingPendingDelete?.(scope, 'test_items', generatedCommandIdentity('delete-uuid')), + ).resolves.toMatchObject({ + identity: { kind: 'generated', localId: 'delete-uuid', remoteId: 42 }, visibility: 'pending_delete', confirmedValues: { title: 'confirmed baseline' }, serverRevision: 7, }); - await expect(repository.getReplicaRowByRemoteIdentity(scope, 'test_items', { serverId: 42 })).resolves.toMatchObject({ - localId: row.localId, + await expect(repository.getReplicaRowByRemoteIdentity(scope, 'test_items', { remoteId: 42 })).resolves.toMatchObject({ + identity: { kind: 'generated', localId: 'delete-uuid', remoteId: 42 }, visibility: 'pending_delete', }); await expect(repository.getCommands(scope)).resolves.toEqual([ @@ -1039,144 +1090,14 @@ describe('IonicOfflineRepository', () => { ]); }); - it('core v4をv5へlosslessに移行し、既存row/commandへdelete metadataのdefaultを付与する', async () => { - const scope = { userId: 1, scopeId: '10' }; - const schemaHash = await sha256OfflineReplicaSchema(replicaSchemaV1); - storage.values.set('offline:metadata', { - schemaVersion: 4, - lastUserId: 1, - replicaSchemaVersion: replicaSchemaV1.version, - replicaSchemaHash: schemaHash, - }); - storage.values.set('offline:replica:rows', { - '1:user:test_items:legacy': { - ...scope, - sourceKey: 'test_items', - localId: 'legacy', - serverId: 42, - values: { title: 'legacy' }, - confirmedValues: { title: 'legacy' }, - serverRevision: 1, - fetchedAt: 1, - syncState: 'confirmed', - }, - }); - storage.values.set('offline:outbox:commands', { - legacy: { - ...scope, - commandId: 'legacy', - aggregateType: 'test_items', - aggregateLocalId: 'legacy', - operation: 'test_items.update', - payload: {}, - optimisticValue: { title: 'legacy' }, - payloadHash: 'hash', - baseRevision: 1, - state: 'pending', - attempts: 0, - retryAt: null, - createdAt: 1, - lastErrorCode: null, - }, - }); - repository = createRepository(replicaSchemaV1, { preserveStorage: true }); - - await repository.initialize(); - - await expect(repository.getReplicaRow(scope, 'test_items', 'legacy')).resolves.toMatchObject({ visibility: 'present' }); - await expect(repository.getCommands(scope)).resolves.toEqual([expect.objectContaining({ replicaMutation: 'upsert' })]); - expect(storage.values.get('offline:metadata')).toMatchObject({ schemaVersion: OFFLINE_SCHEMA_VERSION }); - }); - - it('core v4→v5でROWS保存後にOUTBOX保存が一度失敗しても、reopenで全defaultとmetadataをrepairする', async () => { - const scope = { userId: 1, scopeId: '10' }; - const schemaHash = await sha256OfflineReplicaSchema(replicaSchemaV1); - storage.values.set('offline:metadata', { - schemaVersion: 4, - lastUserId: 1, - replicaSchemaVersion: replicaSchemaV1.version, - replicaSchemaHash: schemaHash, - }); - storage.values.set('offline:replica:rows', { - '1:user:test_items:legacy': { - ...scope, - sourceKey: 'test_items', - localId: 'legacy', - serverId: 42, - values: { title: 'legacy' }, - confirmedValues: { title: 'legacy' }, - serverRevision: 1, - fetchedAt: 1, - syncState: 'confirmed', - }, - }); - storage.values.set('offline:outbox:commands', { - legacy: { - ...scope, - commandId: 'legacy', - aggregateType: 'test_items', - aggregateLocalId: 'legacy', - operation: 'test_items.update', - payload: {}, - optimisticValue: { title: 'legacy' }, - payloadHash: 'hash', - baseRevision: 1, - state: 'pending', - attempts: 0, - retryAt: null, - createdAt: 1, - lastErrorCode: null, - }, - }); - repository = createRepository(replicaSchemaV1, { preserveStorage: true }); - const kitStorage = TestBed.inject(KitStorageService) as MemoryStorage & KitStorageService; - const originalSet = kitStorage.set.bind(kitStorage); - let failOutboxOnce = true; - vi.spyOn(kitStorage, 'set').mockImplementation(async (key, value) => { - if (failOutboxOnce && key === 'offline:outbox:commands') { - failOutboxOnce = false; - throw new Error('injected v5 outbox failure'); - } - return originalSet(key, value) as Promise; - }); - - await expect(repository.initialize()).rejects.toThrow('injected v5 outbox failure'); - expect(storage.values.get('offline:replica:rows')).toMatchObject({ - '1:user:test_items:legacy': expect.objectContaining({ visibility: 'present' }), - }); - expect(storage.values.get('offline:outbox:commands')).toMatchObject({ - legacy: expect.not.objectContaining({ replicaMutation: expect.anything() }), - }); - expect(storage.values.get('offline:metadata')).toMatchObject({ schemaVersion: 4 }); - - vi.restoreAllMocks(); - repository = createRepository(replicaSchemaV1, { preserveStorage: true }); - await repository.initialize(); - - await expect(repository.getReplicaRow(scope, 'test_items', 'legacy')).resolves.toMatchObject({ - visibility: 'present', - }); - await expect(repository.getCommands(scope)).resolves.toEqual([ - expect.objectContaining({ commandId: 'legacy', replicaMutation: 'upsert' }), - ]); - expect(storage.values.get('offline:replica:rows')).toMatchObject({ - '1:user:test_items:legacy': expect.objectContaining({ visibility: 'present' }), - }); - expect(storage.values.get('offline:outbox:commands')).toMatchObject({ - legacy: expect.objectContaining({ replicaMutation: 'upsert' }), - }); - expect(storage.values.get('offline:metadata')).toMatchObject({ schemaVersion: OFFLINE_SCHEMA_VERSION }); - }); - - it('putRowsはvaluesとconfirmedValuesからserverId列を投影で除去する', async () => { + it('putRowsはvaluesとconfirmedValuesからremoteId列を投影で除去する', async () => { await repository.transactReplica({ putRows: [ { userId: 1, scopeId: '10', sourceKey: 'test_items', - localId: '019d-projected', - serverId: 42, + identity: { kind: 'generated', localId: '019d-projected', remoteId: 42 }, values: { id: 42, title: 'Optimistic' }, confirmedValues: { id: 42, title: 'Confirmed' }, serverRevision: 1, @@ -1186,8 +1107,10 @@ describe('IonicOfflineRepository', () => { ], }); - await expect(repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'test_items', '019d-projected')).resolves.toMatchObject({ - serverId: 42, + await expect( + repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'test_items', generatedCommandIdentity('019d-projected')), + ).resolves.toMatchObject({ + identity: { kind: 'generated', localId: '019d-projected', remoteId: 42 }, values: { title: 'Optimistic' }, confirmedValues: { title: 'Confirmed' }, }); @@ -1201,8 +1124,7 @@ describe('IonicOfflineRepository', () => { userId: 1, scopeId: '10', sourceKey: 'test_items', - localId: '019d-bbbb', - serverId: null, + identity: { kind: 'generated', localId: '019d-bbbb', remoteId: null }, values: { id: 0 }, confirmedValues: null, serverRevision: null, @@ -1212,7 +1134,7 @@ describe('IonicOfflineRepository', () => { ], }), ).rejects.toThrow('Replica row is missing required source key "title".'); - expect(await repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'test_items', '019d-bbbb')).toBeNull(); + expect(await repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'test_items', generatedCommandIdentity('019d-bbbb'))).toBeNull(); }); it('未知core schemaではreplica rowsも破壊しない', async () => { @@ -1221,8 +1143,7 @@ describe('IonicOfflineRepository', () => { userId: 1, scopeId: '10', sourceKey: 'test_items', - localId: '019d-bbbb', - serverId: null, + identity: { kind: 'generated', localId: '019d-bbbb', remoteId: null }, values: { id: 0, title: 'Local item' }, confirmedValues: null, serverRevision: null, @@ -1239,36 +1160,42 @@ describe('IonicOfflineRepository', () => { const scope = { userId: 1, scopeId: '10' }; const baseRow = { sourceKey: 'test_items', - serverId: 42, + identity: generatedReplicaIdentity('019d-aaaa', 42), confirmedValues: null, serverRevision: null, fetchedAt: 1, syncState: 'confirmed' as const, }; - it('getReplicaRowByServerIdはuser scopeでscopeIdを無視してlookupする', async () => { + it('getReplicaRowByRemoteIdはuser scopeでscopeIdを無視してlookupする', async () => { await repository.transactReplica({ putRows: [ - { ...baseRow, userId: 1, scopeId: '10', localId: '019d-aaaa', values: { id: 42, title: 'G10' } }, - { ...baseRow, userId: 1, scopeId: '11', localId: '019d-bbbb', serverId: 43, values: { id: 43, title: 'G11' } }, - { ...baseRow, userId: 2, scopeId: '10', localId: '019d-cccc', serverId: 42, values: { id: 42, title: 'Other user' } }, + { ...baseRow, userId: 1, scopeId: '10', values: { id: 42, title: 'G10' } }, + { ...baseRow, userId: 1, scopeId: '11', identity: generatedReplicaIdentity('019d-bbbb', 43), values: { id: 43, title: 'G11' } }, + { + ...baseRow, + userId: 2, + scopeId: '10', + identity: generatedReplicaIdentity('019d-cccc', 42), + values: { id: 42, title: 'Other user' }, + }, ], }); - await expect(repository.getReplicaRowByServerId(scope, 'test_items', 42)).resolves.toMatchObject({ - localId: '019d-aaaa', + await expect(repository.getReplicaRowByRemoteId(scope, 'test_items', 42)).resolves.toMatchObject({ + identity: { kind: 'generated', localId: '019d-aaaa', remoteId: 42 }, values: { title: 'G10' }, }); - await expect(repository.getReplicaRowByServerId(scope, 'test_items', 43)).resolves.toMatchObject({ - localId: '019d-bbbb', + await expect(repository.getReplicaRowByRemoteId(scope, 'test_items', 43)).resolves.toMatchObject({ + identity: { kind: 'generated', localId: '019d-bbbb', remoteId: 43 }, }); - expect(await repository.getReplicaRowByServerId(scope, 'test_items', 99)).toBeNull(); + expect(await repository.getReplicaRowByRemoteId(scope, 'test_items', 99)).toBeNull(); }); - it('getReplicaRowByServerIdはpartition scopeでscopeId一致のみ返す', async () => { + it('getReplicaRowByRemoteIdはpartition scopeでscopeId一致のみ返す', async () => { const groupRow = { sourceKey: 'test_group_items', - serverId: 55, + identity: generatedReplicaIdentity('019d-aaaa', 55), confirmedValues: null, serverRevision: null, fetchedAt: 1, @@ -1276,15 +1203,108 @@ describe('IonicOfflineRepository', () => { }; await repository.transactReplica({ putRows: [ - { ...groupRow, userId: 1, scopeId: '10', localId: '019d-aaaa', values: { id: 55, name: 'G10' } }, - { ...groupRow, userId: 1, scopeId: '11', localId: '019d-bbbb', serverId: 56, values: { id: 56, name: 'G11' } }, + { ...groupRow, userId: 1, scopeId: '10', values: { id: 55, name: 'G10' } }, + { ...groupRow, userId: 1, scopeId: '11', identity: generatedReplicaIdentity('019d-bbbb', 56), values: { id: 56, name: 'G11' } }, ], }); - await expect(repository.getReplicaRowByServerId(scope, 'test_group_items', 55)).resolves.toMatchObject({ - localId: '019d-aaaa', + await expect(repository.getReplicaRowByRemoteId(scope, 'test_group_items', 55)).resolves.toMatchObject({ + identity: { kind: 'generated', localId: '019d-aaaa', remoteId: 55 }, + }); + expect(await repository.getReplicaRowByRemoteId(scope, 'test_group_items', 56)).toBeNull(); + }); + + it('同じlocalIdを別principalと別partitionで独立して保持する', async () => { + const sameLocalId = '019d-shared-local-id'; + await repository.transactReplica({ + putRows: [ + { + ...baseRow, + userId: 1, + scopeId: '10', + identity: generatedReplicaIdentity(sameLocalId, 42), + values: { id: 42, title: 'User 1' }, + }, + { + ...baseRow, + userId: 2, + scopeId: '10', + identity: generatedReplicaIdentity(sameLocalId, 42), + values: { id: 42, title: 'User 2' }, + }, + { + ...baseRow, + sourceKey: 'test_group_items', + userId: 1, + scopeId: '10', + identity: generatedReplicaIdentity(sameLocalId, 55), + values: { id: 55, name: 'Group 10' }, + }, + { + ...baseRow, + sourceKey: 'test_group_items', + userId: 1, + scopeId: '11', + identity: generatedReplicaIdentity(sameLocalId, 55), + values: { id: 55, name: 'Group 11' }, + }, + ], + }); + + await expect( + repository.getReplicaRow({ userId: 2, scopeId: '10' }, 'test_items', generatedCommandIdentity(sameLocalId)), + ).resolves.toMatchObject({ values: { title: 'User 2' } }); + await expect( + repository.getReplicaRow({ userId: 1, scopeId: '11' }, 'test_group_items', generatedCommandIdentity(sameLocalId)), + ).resolves.toMatchObject({ values: { name: 'Group 11' } }); + + await repository.clearScope({ userId: 1, scopeId: '10' }); + await expect( + repository.getReplicaRow({ userId: 1, scopeId: '11' }, 'test_group_items', generatedCommandIdentity(sameLocalId)), + ).resolves.toMatchObject({ values: { name: 'Group 11' } }); + await expect( + repository.getReplicaRow({ userId: 2, scopeId: '10' }, 'test_items', generatedCommandIdentity(sameLocalId)), + ).resolves.toMatchObject({ values: { title: 'User 2' } }); + }); + + it('TEXT generated idをnullからUUIDへ割り当て、lookup・collision・restartを同じ型で扱う', async () => { + repository = createRepository(textIdSchema); + const textScope = { userId: 1, scopeId: '10' }; + const localId = 'text-local-id'; + const remoteId = '018f6f6e-74ad-7cc4-b94f-4af0b13c4401'; + const row = (nextRemoteId: string | null, title: string): OfflineReplicaRow => ({ + ...textScope, + sourceKey: 'text_id_items', + identity: generatedReplicaIdentity(localId, nextRemoteId), + values: { id: nextRemoteId ?? '', title }, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'pending', + }); + await repository.transactReplica({ putRows: [row(null, 'local')] }); + await repository.transactReplica({ putRows: [row(remoteId, 'confirmed')] }); + await expect(repository.getReplicaRowByRemoteId(textScope, 'text_id_items', remoteId)).resolves.toMatchObject({ + identity: { kind: 'generated', localId, remoteId }, + }); + await expect( + repository.transactReplica({ + putRows: [ + { + ...row(remoteId, 'collision'), + identity: generatedReplicaIdentity('another-local-id', remoteId), + }, + ], + }), + ).rejects.toThrow('already mapped'); + await expect(repository.getReplicaRowByRemoteId(textScope, 'text_id_items', 42)).rejects.toThrow( + 'generated remote id must be a non-empty string', + ); + + repository = createRepository(textIdSchema, { preserveStorage: true }); + await expect(repository.getReplicaRowByRemoteId(textScope, 'text_id_items', remoteId)).resolves.toMatchObject({ + identity: { kind: 'generated', localId, remoteId }, }); - expect(await repository.getReplicaRowByServerId(scope, 'test_group_items', 56)).toBeNull(); }); it('putCursorsはrow更新と同一transactionで原子的に永続化する', async () => { @@ -1294,8 +1314,7 @@ describe('IonicOfflineRepository', () => { userId: 1, scopeId: '10', sourceKey: 'test_items', - localId: '019d-aaaa', - serverId: 42, + identity: { kind: 'generated', localId: '019d-aaaa', remoteId: 42 }, values: { id: 42, title: 'Pulled' }, confirmedValues: { id: 42, title: 'Pulled' }, serverRevision: 1, @@ -1307,7 +1326,7 @@ describe('IonicOfflineRepository', () => { }); await expect(repository.getReplicaCursor(scope)).resolves.toEqual({ userId: 1, scopeId: '10', cursor: 'cursor-v1' }); - await expect(repository.getReplicaRowByServerId(scope, 'test_items', 42)).resolves.toMatchObject({ + await expect(repository.getReplicaRowByRemoteId(scope, 'test_items', 42)).resolves.toMatchObject({ values: { title: 'Pulled' }, }); }); @@ -1320,8 +1339,7 @@ describe('IonicOfflineRepository', () => { userId: 1, scopeId: '10', sourceKey: 'test_items', - localId: '019d-aaaa', - serverId: 42, + identity: { kind: 'generated', localId: '019d-aaaa', remoteId: 42 }, values: { id: 42 }, confirmedValues: null, serverRevision: null, @@ -1368,11 +1386,11 @@ describe('IonicOfflineRepository', () => { }); }); - describe('replica serverId uniqueness', () => { + describe('replica remoteId uniqueness', () => { const scope = { userId: 1, scopeId: '10' }; const groupRow = { sourceKey: 'test_group_items' as const, - serverId: 55, + identity: generatedReplicaIdentity('019d-aaaa', 55), confirmedValues: null, serverRevision: null, fetchedAt: 1, @@ -1380,21 +1398,20 @@ describe('IonicOfflineRepository', () => { }; const userRow = { sourceKey: 'test_items' as const, - serverId: 42, + identity: generatedReplicaIdentity('019d-aaaa', 42), confirmedValues: null, serverRevision: null, fetchedAt: 1, syncState: 'confirmed' as const, }; - it('partition-scopedで別localIdに同じserverIdを割り当てるとrejectする', async () => { + it('partition-scopedで別localIdに同じremoteIdを割り当てるとrejectする', async () => { await repository.transactReplica({ putRows: [ { ...groupRow, userId: 1, scopeId: '10', - localId: '019d-aaaa', values: { id: 55, name: 'A' }, }, ], @@ -1406,22 +1423,21 @@ describe('IonicOfflineRepository', () => { ...groupRow, userId: 1, scopeId: '10', - localId: '019d-bbbb', + identity: generatedReplicaIdentity('019d-bbbb', 55), values: { id: 55, name: 'B' }, }, ], }), - ).rejects.toThrow('Offline replica serverId 55 is already mapped to localId 019d-aaaa.'); + ).rejects.toThrow('Offline replica remote id 55 is already mapped to 019d-aaaa.'); }); - it('user-scopedで別localIdに同じserverIdを割り当てるとrejectする', async () => { + it('user-scopedで別localIdに同じremoteIdを割り当てるとrejectする', async () => { await repository.transactReplica({ putRows: [ { ...userRow, userId: 1, scopeId: '10', - localId: '019d-aaaa', values: { id: 42, title: 'A' }, }, ], @@ -1433,15 +1449,15 @@ describe('IonicOfflineRepository', () => { ...userRow, userId: 1, scopeId: '10', - localId: '019d-bbbb', + identity: generatedReplicaIdentity('019d-bbbb', 42), values: { id: 42, title: 'B' }, }, ], }), - ).rejects.toThrow('Offline replica serverId 42 is already mapped to localId 019d-aaaa.'); + ).rejects.toThrow('Offline replica remote id 42 is already mapped to 019d-aaaa.'); }); - it('同一transaction内のserverId重複は部分永続化せずrejectする', async () => { + it('同一transaction内のremoteId重複は部分永続化せずrejectする', async () => { await expect( repository.transactReplica({ putRows: [ @@ -1449,58 +1465,55 @@ describe('IonicOfflineRepository', () => { ...groupRow, userId: 1, scopeId: '10', - localId: '019d-aaaa', values: { id: 55, name: 'A' }, }, { ...groupRow, userId: 1, scopeId: '10', - localId: '019d-bbbb', + identity: generatedReplicaIdentity('019d-bbbb', 55), 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(); + ).rejects.toThrow('Offline replica remote id 55 is already mapped to 019d-aaaa.'); + expect(await repository.getReplicaRow(scope, 'test_group_items', generatedCommandIdentity('019d-aaaa'))).toBeNull(); + expect(await repository.getReplicaRow(scope, 'test_group_items', generatedCommandIdentity('019d-bbbb'))).toBeNull(); }); - it('partition-scopedは別partitionなら同じserverIdを許容する', async () => { + it('partition-scopedは別partitionなら同じremoteIdを許容する', async () => { await repository.transactReplica({ putRows: [ { ...groupRow, userId: 1, scopeId: '10', - localId: '019d-aaaa', values: { id: 55, name: 'G10' }, }, { ...groupRow, userId: 1, scopeId: '11', - localId: '019d-bbbb', + identity: generatedReplicaIdentity('019d-bbbb', 55), values: { id: 55, name: 'G11' }, }, ], }); - await expect(repository.getReplicaRowByServerId(scope, 'test_group_items', 55)).resolves.toMatchObject({ - localId: '019d-aaaa', + await expect(repository.getReplicaRowByRemoteId(scope, 'test_group_items', 55)).resolves.toMatchObject({ + identity: { kind: 'generated', localId: '019d-aaaa', remoteId: 55 }, }); - await expect(repository.getReplicaRowByServerId({ userId: 1, scopeId: '11' }, 'test_group_items', 55)).resolves.toMatchObject({ - localId: '019d-bbbb', + await expect(repository.getReplicaRowByRemoteId({ userId: 1, scopeId: '11' }, 'test_group_items', 55)).resolves.toMatchObject({ + identity: { kind: 'generated', localId: '019d-bbbb', remoteId: 55 }, }); }); - it('user-scopedは別partitionでも同じserverIdをrejectする', async () => { + it('user-scopedは別partitionでも同じremoteIdをrejectする', async () => { await repository.transactReplica({ putRows: [ { ...userRow, userId: 1, scopeId: '10', - localId: '019d-aaaa', values: { id: 42, title: 'G10' }, }, ], @@ -1512,22 +1525,22 @@ describe('IonicOfflineRepository', () => { ...userRow, userId: 1, scopeId: '11', - localId: '019d-bbbb', + identity: generatedReplicaIdentity('019d-bbbb', 42), values: { id: 42, title: 'G11' }, }, ], }), - ).rejects.toThrow('Offline replica serverId 42 is already mapped to localId 019d-aaaa.'); + ).rejects.toThrow('Offline replica remote id 42 is already mapped to 019d-aaaa.'); }); }); describe('naturalKey identity', () => { - const row = (scopeId: string, localId: string, label: string): OfflineReplicaRow => ({ + const favoriteNaturalKey = { favFrom: 7, favTo: '42' }; + const row = (scopeId: string, label: string): OfflineReplicaRow => ({ userId: 1, scopeId, sourceKey: 'natural_favorites', - localId, - serverId: null, + identity: naturalReplicaIdentity(favoriteNaturalKey), values: { favFrom: 7, favTo: '42', label }, confirmedValues: null, serverRevision: null, @@ -1539,31 +1552,36 @@ describe('IonicOfflineRepository', () => { repository = createRepository(naturalFavoriteSchema); }); - it('same scopeのcomposite identity collisionをrejectする', async () => { - await repository.transactReplica({ putRows: [row('10', 'uuid-a', 'A')] }); - await expect(repository.transactReplica({ putRows: [row('10', 'uuid-b', 'B')] })).rejects.toThrow( - 'Offline replica remote identity is already mapped to localId uuid-a.', - ); + it('same scopeの同一composite identityを同じrowとして更新する', async () => { + await repository.transactReplica({ putRows: [row('10', 'A')] }); + await repository.transactReplica({ putRows: [row('10', 'B')] }); + await expect( + repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'natural_favorites', naturalCommandIdentity(favoriteNaturalKey)), + ).resolves.toMatchObject({ values: { label: 'B' } }); }); it('partitionが異なれば同じnaturalKeyを許可しidentity lookupできる', async () => { - await repository.transactReplica({ putRows: [row('10', 'uuid-a', 'A'), row('11', 'uuid-b', 'B')] }); + await repository.transactReplica({ putRows: [row('10', 'A'), row('11', 'B')] }); await expect( repository.getReplicaRowByRemoteIdentity({ userId: 1, scopeId: '11' }, 'natural_favorites', { naturalKey: { favTo: '42', favFrom: 7 }, }), - ).resolves.toMatchObject({ localId: 'uuid-b', serverId: null }); + ).resolves.toMatchObject({ + identity: naturalReplicaIdentity(favoriteNaturalKey), + }); }); - it('同一localIdのnaturalKey再割当をdirect transactionでもrejectする', async () => { - await repository.transactReplica({ putRows: [row('10', 'uuid-a', 'A')] }); + it('同一naturalKeyの再割当をdirect transactionでもrejectする', async () => { + await repository.transactReplica({ putRows: [row('10', 'A')] }); await expect( repository.transactReplica({ - putRows: [{ ...row('10', 'uuid-a', 'changed'), values: { favFrom: 8, favTo: '42', label: 'changed' } }], + putRows: [{ ...row('10', 'changed'), values: { favFrom: 8, favTo: '42', label: 'changed' } }], }), - ).rejects.toThrow('Offline replica naturalKey is immutable for "natural_favorites".'); - await expect(repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'natural_favorites', 'uuid-a')).resolves.toMatchObject({ + ).rejects.toThrow('Offline replica identity naturalKey must match values for "natural_favorites".'); + await expect( + repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'natural_favorites', naturalCommandIdentity(favoriteNaturalKey)), + ).resolves.toMatchObject({ values: { favFrom: 7, favTo: '42', label: 'A' }, }); }); @@ -1573,56 +1591,68 @@ describe('IonicOfflineRepository', () => { repository.transactReplica({ putRows: [ { - ...row('10', 'uuid-mismatch', 'optimistic'), + ...row('10', 'optimistic'), confirmedValues: { favFrom: 8, favTo: '42', label: 'confirmed' }, }, ], }), ).rejects.toThrow('Offline replica confirmedValues naturalKey must match values for "natural_favorites".'); - await expect(repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'natural_favorites', 'uuid-mismatch')).resolves.toBeNull(); + await expect( + repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'natural_favorites', naturalCommandIdentity(favoriteNaturalKey)), + ).resolves.toBeNull(); }); }); describe('getReplicaRows', () => { const baseRow = { sourceKey: 'test_items', - serverId: null, + identity: generatedReplicaIdentity('019d-placeholder', null), confirmedValues: null, serverRevision: null, fetchedAt: 1, syncState: 'pending' as const, }; - it('localId昇順で決定的に返す', async () => { + it('identity昇順で決定的に返す', async () => { await repository.transactReplica({ putRows: [ - { ...baseRow, userId: 1, scopeId: '10', localId: '019d-cccc', values: { id: 0, title: 'C' } }, - { ...baseRow, userId: 1, scopeId: '10', localId: '019d-aaaa', values: { id: 0, title: 'A' } }, - { ...baseRow, userId: 1, scopeId: '10', localId: '019d-bbbb', values: { id: 0, title: 'B' } }, + { ...baseRow, userId: 1, scopeId: '10', identity: generatedReplicaIdentity('019d-cccc', null), values: { id: 0, title: 'C' } }, + { ...baseRow, userId: 1, scopeId: '10', identity: generatedReplicaIdentity('019d-aaaa', null), values: { id: 0, title: 'A' } }, + { ...baseRow, userId: 1, scopeId: '10', identity: generatedReplicaIdentity('019d-bbbb', null), values: { id: 0, title: 'B' } }, ], }); const rows = await repository.getReplicaRows({ userId: 1, scopeId: '10' }, 'test_items'); - expect(rows.map((row) => row.localId)).toEqual(['019d-aaaa', '019d-bbbb', '019d-cccc']); + expect(rows.map((row) => canonicalOfflineReplicaIdentity(row.identity))).toEqual([ + 'generated:019d-aaaa', + 'generated:019d-bbbb', + 'generated:019d-cccc', + ]); }); it('user-scoped sourceはscopeIdを無視して同一userの行を返す', async () => { await repository.transactReplica({ putRows: [ - { ...baseRow, userId: 1, scopeId: '10', localId: '019d-aaaa', values: { id: 0, title: 'G10' } }, - { ...baseRow, userId: 1, scopeId: '11', localId: '019d-bbbb', values: { id: 0, title: 'G11' } }, - { ...baseRow, userId: 2, scopeId: '10', localId: '019d-cccc', values: { id: 0, title: 'Other user' } }, + { ...baseRow, userId: 1, scopeId: '10', identity: generatedReplicaIdentity('019d-aaaa', null), values: { id: 0, title: 'G10' } }, + { ...baseRow, userId: 1, scopeId: '11', identity: generatedReplicaIdentity('019d-bbbb', null), values: { id: 0, title: 'G11' } }, + { + ...baseRow, + userId: 2, + scopeId: '10', + identity: generatedReplicaIdentity('019d-cccc', null), + values: { id: 0, title: 'Other user' }, + }, ], }); const rows = await repository.getReplicaRows({ userId: 1, scopeId: '10' }, 'test_items'); - expect(rows.map((row) => row.localId)).toEqual(['019d-aaaa', '019d-bbbb']); + expect(rows.map((row) => canonicalOfflineReplicaIdentity(row.identity))).toEqual(['generated:019d-aaaa', 'generated:019d-bbbb']); }); it('partition-scoped sourceはscopeId一致の行だけを返す', async () => { const groupRow = { sourceKey: 'test_group_items', - serverId: null, + identity: generatedReplicaIdentity('019d-placeholder', null), confirmedValues: null, serverRevision: null, fetchedAt: 1, @@ -1630,14 +1660,14 @@ describe('IonicOfflineRepository', () => { }; await repository.transactReplica({ putRows: [ - { ...groupRow, userId: 1, scopeId: '10', localId: '019d-aaaa', values: { id: 0, name: 'G10' } }, - { ...groupRow, userId: 1, scopeId: '11', localId: '019d-bbbb', values: { id: 0, name: 'G11' } }, + { ...groupRow, userId: 1, scopeId: '10', identity: generatedReplicaIdentity('019d-aaaa', null), values: { id: 0, name: 'G10' } }, + { ...groupRow, userId: 1, scopeId: '11', identity: generatedReplicaIdentity('019d-bbbb', null), values: { id: 0, name: 'G11' } }, ], }); const rows = await repository.getReplicaRows({ userId: 1, scopeId: '10' }, 'test_group_items'); expect(rows).toHaveLength(1); - expect(rows[0]?.localId).toBe('019d-aaaa'); + expect(rows[0]?.identity).toEqual(generatedReplicaIdentity('019d-aaaa', null)); }); }); }); diff --git a/projects/kit/offline/src/lib/offline-repository.ts b/projects/kit/offline/src/lib/offline-repository.ts index 4488960..dca3a30 100644 --- a/projects/kit/offline/src/lib/offline-repository.ts +++ b/projects/kit/offline/src/lib/offline-repository.ts @@ -1,25 +1,54 @@ import { inject, Injectable, InjectionToken } from '@angular/core'; import { KitStorageService } from '@rdlabo/ionic-angular-kit'; +import { + canonicalOfflineCommandIdentity, + canonicalOfflinePrincipalId, + canonicalOfflineReplicaIdentity, + commandIdentityFromReplicaIdentity, + offlineReplicaRemoteIdentity, + type OfflineCommandIdentity, + type OfflineReplicaAddress, + type OfflineReplicaIdentity, + type OfflinePrincipalId, +} from './offline-identity'; import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; import { - assertOfflineReplicaServerId, + assertOfflineReplicaGeneratedRemoteId, assertOfflineReplicaNaturalKeyBaseline, canonicalOfflineRemoteIdentity, encodeOfflineReplicaValues, + normalizeOfflineNaturalKey, offlineNaturalKeyFromValues, projectOfflineReplicaValues, sha256OfflineReplicaSchema, + type OfflineGeneratedRemoteId, type OfflineReplicaEntitySchema, type OfflineReplicaRemoteIdentity, type OfflineReplicaWebMigrationRow, } from './offline-replica-schema'; +export type { OfflineCommandIdentity, OfflinePrincipalId, OfflineReplicaAddress, OfflineReplicaIdentity } from './offline-identity'; +export { + canonicalOfflineCommandIdentity, + canonicalOfflinePrincipalId, + canonicalOfflineReplicaIdentity, + commandIdentityFromReplicaIdentity, + commandIdentityMatchesReplicaRow, + offlineGeneratedReplicaIdentity, + offlineNaturalReplicaIdentity, + offlineReplicaRemoteIdentity, + parseOfflineCommandIdentity, + parseOfflinePrincipalId, + replicaAddressFromIdentity, + serializeOfflineCommandIdentity, +} from './offline-identity'; + /** Current durable storage schema used by both web and native repositories. */ -export const OFFLINE_SCHEMA_VERSION = 5; +export const OFFLINE_SCHEMA_VERSION = 1; /** User and partition scope of all local offline data. */ export interface OfflineScope { - userId: number; + userId: OfflinePrincipalId; scopeId: string; } @@ -31,12 +60,13 @@ export type OfflineReplicaMutation = 'upsert' | 'delete'; /** Durable processing state of an outbox command. */ export type OfflineCommandState = 'pending' | 'sending' | 'retry_wait' | 'blocked_auth' | 'rejected' | 'conflict'; -/** Product-agnostic mutation persisted in the outbox by local id. */ -export interface OfflineCommand extends OfflineScope { +interface OfflineCommandBase extends OfflineScope { commandId: string; aggregateType: string; - /** Immutable local id of the target. The outbox never persists a server id. */ - aggregateLocalId: string; + /** Replica schema source key resolved when the command is enqueued. */ + sourceKey: string; + /** Stable row identity. The Outbox never persists a generated server id. */ + identity: OfflineCommandIdentity; operation: string; payload: T; /** Full optimistic entity value displayed while this command is pending. */ @@ -52,14 +82,14 @@ export interface OfflineCommand extends OfflineScope { lastErrorCode: string | null; } +export type OfflineCommand = OfflineCommandBase; + /** Product replica row materialized from a versioned schema entity. */ -export interface OfflineReplicaRow extends OfflineScope { +interface OfflineReplicaRowBase extends OfflineScope { /** Stable source key matching {@link OfflineReplicaEntitySchema.sourceKey}. */ sourceKey: string; - /** Immutable client-generated UUID used as the SQLite primary key. */ - localId: string; - /** Server-assigned identifier after a successful create, otherwise null. */ - serverId: number | null; + /** Row identity matching the entity schema. Natural-key rows have no synthetic ids. */ + identity: OfflineReplicaIdentity; /** Current optimistic domain values displayed locally. */ values: TValues; /** Last server-confirmed domain values, or null while pending. */ @@ -71,16 +101,18 @@ export interface OfflineReplicaRow extends OfflineScope { visibility?: OfflineReplicaVisibility; } +export type OfflineReplicaRow = OfflineReplicaRowBase; + /** Stable address of a product replica row inside a user or partition-scoped replica. */ export interface OfflineReplicaRowKey extends OfflineScope { sourceKey: string; - localId: string; + identity: OfflineReplicaIdentity; } -/** Explicit one-way release of a server-generated identity during a replica delete acknowledgement. */ -export interface OfflineReplicaServerIdRelease extends OfflineReplicaRowKey { - /** The current server id being released; the matching put row must set `serverId` to null. */ - serverId: number; +/** Explicit one-way release of a generated remote id during a replica delete acknowledgement. */ +export interface OfflineReplicaRemoteIdRelease extends OfflineReplicaRowKey { + /** The current remote id being released; the matching put row must set `remoteId` to null. */ + remoteId: OfflineGeneratedRemoteId; } /** Scope partition plus the durable replica pull cursor for that partition. */ @@ -92,10 +124,10 @@ export interface OfflineReplicaCursor extends OfflineScope { export interface OfflineReplicaTransaction { putRows?: readonly OfflineReplicaRow[]; /** - * Allows only the matching `serverId: current -> null` transition in `putRows`. - * All other server-id changes remain immutable. + * Allows only the matching `remoteId: current -> null` transition in `putRows`. + * All other remote-id changes remain immutable. */ - releaseServerIds?: readonly OfflineReplicaServerIdRelease[]; + releaseRemoteIds?: readonly OfflineReplicaRemoteIdRelease[]; removeRows?: readonly OfflineReplicaRowKey[]; putCommands?: readonly OfflineCommand[]; removeCommandIds?: readonly string[]; @@ -105,22 +137,26 @@ export interface OfflineReplicaTransaction { /** Durable local replica and outbox persistence contract. */ export interface OfflineRepository { initialize(): Promise; - getLastUserId(): Promise; - setLastUserId(userId: number): Promise; - getSessionManifest(userId: number): Promise; - putSessionManifest(userId: number, value: T): Promise; - getReplicaRow(scope: OfflineScope, sourceKey: string, localId: string): Promise | null>; + getLastUserId(): Promise; + setLastUserId(userId: OfflinePrincipalId): Promise; + getSessionManifest(userId: OfflinePrincipalId): Promise; + putSessionManifest(userId: OfflinePrincipalId, value: T): Promise; + getReplicaRow( + scope: OfflineScope, + sourceKey: string, + identity: OfflineReplicaAddress, + ): Promise | null>; /** Internal durable lookup used by synchronization; includes pending-delete tombstones. */ getReplicaRowIncludingPendingDelete?( scope: OfflineScope, sourceKey: string, - localId: string, + identity: OfflineReplicaAddress, ): Promise | null>; getReplicaRows(scope: OfflineScope, sourceKey: string): Promise[]>; - getReplicaRowByServerId( + getReplicaRowByRemoteId( scope: OfflineScope, sourceKey: string, - serverId: number, + remoteId: OfflineGeneratedRemoteId, ): Promise | null>; getReplicaRowByRemoteIdentity( scope: OfflineScope, @@ -129,11 +165,11 @@ export interface OfflineRepository { ): Promise | null>; getReplicaCursor(scope: OfflineScope): Promise; getCommands(scope: OfflineScope): Promise; - getCommandsForUser?(userId: number): Promise; + getCommandsForUser?(userId: OfflinePrincipalId): Promise; putCommand(command: OfflineCommand): Promise; replaceCommand(command: OfflineCommand): Promise; removeCommand(commandId: string): Promise; - clearUser(userId: number): Promise; + clearUser(userId: OfflinePrincipalId): Promise; clearScope(scope: OfflineScope): Promise; transactReplica(transaction: OfflineReplicaTransaction): Promise; } @@ -152,7 +188,7 @@ export function selectOfflineRepository( interface OfflineMetadata { schemaVersion: number; - lastUserId: number | null; + lastUserId: OfflinePrincipalId | null; replicaSchemaVersion: number | null; replicaSchemaHash: string | null; } @@ -196,27 +232,27 @@ export class IonicOfflineRepository implements OfflineRepository { return this.#initialization; } - async getLastUserId(): Promise { + async getLastUserId(): Promise { await this.initialize(); return (await this.#metadata()).lastUserId; } - async setLastUserId(userId: number): Promise { + async setLastUserId(userId: OfflinePrincipalId): Promise { await this.initialize(); await this.#storage.set(METADATA_KEY, { ...(await this.#metadata()), lastUserId: userId }); } - async getSessionManifest(userId: number): Promise { + async getSessionManifest(userId: OfflinePrincipalId): Promise { await this.initialize(); await this.#writes; const manifests = await this.#readRecord(SESSION_MANIFESTS_KEY); - return manifests[String(userId)] ?? null; + return manifests[canonicalOfflinePrincipalId(userId)] ?? null; } - async putSessionManifest(userId: number, value: T): Promise { + async putSessionManifest(userId: OfflinePrincipalId, value: T): Promise { await this.initialize(); await this.#mutateRecord(SESSION_MANIFESTS_KEY, (manifests) => { - manifests[String(userId)] = value; + manifests[canonicalOfflinePrincipalId(userId)] = value; return manifests; }); } @@ -224,13 +260,13 @@ export class IonicOfflineRepository implements OfflineRepository { async getReplicaRow( scope: OfflineScope, sourceKey: string, - localId: string, + identity: OfflineReplicaAddress, ): Promise | null> { await this.initialize(); await this.#writes; const schema = this.#resolveReplicaEntitySchema(sourceKey); const rows = await this.#readRecord>(ROWS_KEY); - const row = rows[this.#rowKey(scope, sourceKey, localId)]; + const row = this.#findRowByAddress(rows, scope, sourceKey, schema, identity); if (!row || (row.visibility ?? 'present') === 'pending_delete') return null; return this.#rowForScope(row, schema, scope) as OfflineReplicaRow; } @@ -238,13 +274,13 @@ export class IonicOfflineRepository implements OfflineRepository { async getReplicaRowIncludingPendingDelete( scope: OfflineScope, sourceKey: string, - localId: string, + identity: OfflineReplicaAddress, ): Promise | null> { await this.initialize(); await this.#writes; const schema = this.#resolveReplicaEntitySchema(sourceKey); const rows = await this.#readRecord>(ROWS_KEY); - const row = rows[this.#rowKey(scope, sourceKey, localId)]; + const row = this.#findRowByAddress(rows, scope, sourceKey, schema, identity); return row ? (this.#rowForScope(row, schema, scope) as OfflineReplicaRow) : null; } @@ -260,24 +296,16 @@ export class IonicOfflineRepository implements OfflineRepository { return schema.scope === 'partition' ? row.scopeId === scope.scopeId : true; }) .map((row) => this.#rowForScope(row, schema, scope)) - .sort((left, right) => left.localId.localeCompare(right.localId)); + .sort((left, right) => this.#compareReplicaIdentity(schema, left.identity, right.identity)); } - async getReplicaRowByServerId( + async getReplicaRowByRemoteId( scope: OfflineScope, sourceKey: string, - serverId: number, + remoteId: OfflineGeneratedRemoteId, ): Promise | null> { - await this.initialize(); - await this.#writes; - const schema = this.#resolveReplicaEntitySchema(sourceKey); - if (!this.#schemaHasServerId(schema)) return null; - const rows = await this.#readRecord>(ROWS_KEY); - const row = Object.values(rows).find((row) => { - if (row.sourceKey !== sourceKey || row.userId !== scope.userId || row.serverId !== serverId) return false; - return schema.scope === 'partition' ? row.scopeId === scope.scopeId : true; - }); - return row ? (this.#rowForScope(row, schema, scope) as OfflineReplicaRow) : null; + if (this.#resolveReplicaEntitySchema(sourceKey).identity.kind !== 'generated') return null; + return this.getReplicaRowByRemoteIdentity(scope, sourceKey, { remoteId }); } async getReplicaRowByRemoteIdentity( @@ -293,7 +321,7 @@ export class IonicOfflineRepository implements OfflineRepository { const row = Object.values(rows).find((candidate) => { if (candidate.sourceKey !== sourceKey || candidate.userId !== scope.userId) return false; if (schema.scope === 'partition' && candidate.scopeId !== scope.scopeId) return false; - const candidateIdentity = this.#rowRemoteIdentity(schema, candidate); + const candidateIdentity = offlineReplicaRemoteIdentity(schema, candidate.identity); return candidateIdentity !== null && canonicalOfflineRemoteIdentity(schema, candidateIdentity) === canonical; }); return row ? (this.#rowForScope(row, schema, scope) as OfflineReplicaRow) : null; @@ -316,7 +344,7 @@ export class IonicOfflineRepository implements OfflineRepository { .sort(compareOfflineCommands); } - async getCommandsForUser(userId: number): Promise { + async getCommandsForUser(userId: OfflinePrincipalId): Promise { await this.initialize(); await this.#writes; const commands = await this.#readRecord(OUTBOX_KEY); @@ -346,16 +374,16 @@ export class IonicOfflineRepository implements OfflineRepository { }); } - async clearUser(userId: number): Promise { + async clearUser(userId: OfflinePrincipalId): Promise { await this.initialize(); await Promise.all([ this.#mutateRecord(SESSION_MANIFESTS_KEY, (manifests) => { - delete manifests[String(userId)]; + delete manifests[canonicalOfflinePrincipalId(userId)]; return manifests; }), this.#filterRecord(ROWS_KEY, (value) => value.userId !== userId), this.#filterRecord(OUTBOX_KEY, (value) => value.userId !== userId), - this.#filterRecord(CURSORS_KEY, (_value, key) => !key.startsWith(`${userId}:`)), + this.#filterRecord(CURSORS_KEY, (_value, key) => !key.startsWith(`${canonicalOfflinePrincipalId(userId)}:`)), ]); const metadata = await this.#metadata(); if (metadata.lastUserId === userId) { @@ -371,7 +399,10 @@ export class IonicOfflineRepository implements OfflineRepository { const schema = this.#resolveReplicaEntitySchema(value.sourceKey); return schema.scope === 'user' || !belongsToGroup(value); }), - this.#filterRecord(OUTBOX_KEY, (value) => !belongsToGroup(value)), + this.#filterRecord(OUTBOX_KEY, (value) => { + const schema = this.#resolveReplicaEntitySchema(value.sourceKey); + return schema.scope === 'user' || !belongsToGroup(value); + }), this.#filterRecord(CURSORS_KEY, (_value, key) => key !== this.#cursorKey(scope)), ]); } @@ -384,26 +415,7 @@ export class IonicOfflineRepository implements OfflineRepository { } async #migrate(): Promise { - let metadata = await this.#storage.get>(METADATA_KEY); - if (metadata?.schemaVersion === 4) { - const rows = await this.#readRecord(ROWS_KEY); - const commands = await this.#readRecord(OUTBOX_KEY); - await this.#storage.set( - ROWS_KEY, - Object.fromEntries(Object.entries(rows).map(([key, row]) => [key, { ...row, visibility: row.visibility ?? 'present' }])), - ); - await this.#storage.set( - OUTBOX_KEY, - Object.fromEntries( - Object.entries(commands).map(([key, command]) => [ - key, - { ...command, replicaMutation: command.replicaMutation ?? 'upsert' }, - ]), - ), - ); - metadata = { ...metadata, schemaVersion: OFFLINE_SCHEMA_VERSION }; - await this.#storage.set(METADATA_KEY, metadata); - } + const metadata = await this.#storage.get>(METADATA_KEY); if (metadata?.schemaVersion !== undefined && metadata.schemaVersion !== OFFLINE_SCHEMA_VERSION) { throw new Error( `Unsupported offline storage schema version ${metadata.schemaVersion}; expected ${OFFLINE_SCHEMA_VERSION}. ` + @@ -520,7 +532,7 @@ export class IonicOfflineRepository implements OfflineRepository { values: projectOfflineReplicaValues(entitySchema, current!.values), confirmedValues: current!.confirmedValues === null ? null : projectOfflineReplicaValues(entitySchema, current!.confirmedValues), }; - const transformedKey = this.#rowKey(transformedRow, transformedRow.sourceKey, transformedRow.localId); + const transformedKey = this.#rowKey(transformedRow); if (transformedRows[transformedKey]) { throw new Error(`Replica schema migration produced duplicate row key "${transformedKey}".`); } @@ -586,22 +598,26 @@ export class IonicOfflineRepository implements OfflineRepository { this.#readRecord(CURSORS_KEY), ]); const identityCheckRows = { ...rows }; - const releases = new Map(); - for (const release of transaction.releaseServerIds ?? []) { - if (!Number.isSafeInteger(release.serverId) || release.serverId <= 0) { - throw new Error(`Offline replica release has invalid serverId ${String(release.serverId)}.`); + const releases = new Map(); + for (const release of transaction.releaseRemoteIds ?? []) { + this.#assertValidReleaseRemoteId(release.remoteId); + const key = this.#rowKey(release); + if (releases.has(key)) { + throw new Error( + `Offline replica remoteId release is duplicated for ${release.sourceKey}/${canonicalOfflineReplicaIdentity(release.identity)}.`, + ); } - const key = this.#rowKey(release, release.sourceKey, release.localId); - if (releases.has(key)) throw new Error(`Offline replica serverId release is duplicated for ${release.sourceKey}/${release.localId}.`); releases.set(key, release); } const consumedReleases = new Set(); for (const row of transaction.putRows ?? []) { - const key = this.#rowKey(row, row.sourceKey, row.localId); + const key = this.#rowKey(row); const existing = identityCheckRows[key]; const release = releases.get(key); if (!existing && release) { - throw new Error(`Offline replica serverId release requires an existing row for ${row.sourceKey}/${row.localId}.`); + throw new Error( + `Offline replica remoteId release requires an existing row for ${row.sourceKey}/${canonicalOfflineReplicaIdentity(row.identity)}.`, + ); } if (existing) this.#assertReplicaIdentityAssignment(existing, row, release); if (release) consumedReleases.add(key); @@ -609,12 +625,12 @@ export class IonicOfflineRepository implements OfflineRepository { identityCheckRows[key] = row; } if (consumedReleases.size !== releases.size) { - throw new Error('Offline replica serverId release must match an existing row in putRows.'); + throw new Error('Offline replica remoteId release must match an existing row in putRows.'); } 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)] = { + rows[this.#rowKey(row)] = { ...row, scopeId: schema.scope === 'user' ? '' : row.scopeId, values: projectOfflineReplicaValues(schema, row.values), @@ -622,7 +638,7 @@ export class IonicOfflineRepository implements OfflineRepository { }; } for (const row of transaction.removeRows ?? []) { - delete rows[this.#rowKey(row, row.sourceKey, row.localId)]; + delete rows[this.#rowKey(row)]; } for (const command of transaction.putCommands ?? []) commands[command.commandId] = command; for (const commandId of transaction.removeCommandIds ?? []) delete commands[commandId]; @@ -666,10 +682,34 @@ export class IonicOfflineRepository implements OfflineRepository { return write; } - #rowKey(scope: OfflineScope, sourceKey: string, localId: string): string { - const schema = this.#resolveReplicaEntitySchema(sourceKey); - const partition = schema.scope === 'user' ? 'user' : String(scope.scopeId); - return `${scope.userId}:${partition}:${sourceKey}:${localId}`; + #rowKey(row: OfflineScope & { sourceKey: string; identity: OfflineReplicaIdentity }): string { + const schema = this.#resolveReplicaEntitySchema(row.sourceKey); + const partition = schema.scope === 'user' ? 'user' : String(row.scopeId); + return `${canonicalOfflinePrincipalId(row.userId)}:${partition}:${row.sourceKey}:${canonicalOfflineReplicaIdentity(row.identity)}`; + } + + #findRowByAddress( + rows: Record>, + scope: OfflineScope, + sourceKey: string, + schema: OfflineReplicaEntitySchema>, + identity: OfflineReplicaAddress, + ): OfflineReplicaRow | undefined { + return Object.values(rows).find((row) => { + if (row.sourceKey !== sourceKey || row.userId !== scope.userId) return false; + if (schema.scope === 'partition' && row.scopeId !== scope.scopeId) return false; + if (identity.kind === 'generated') { + return row.identity.kind === 'generated' && row.identity.localId === identity.localId; + } + if (identity.kind === 'local') { + return row.identity.kind === 'local' && row.identity.localId === identity.localId; + } + if (row.identity.kind !== 'natural') return false; + return ( + canonicalOfflineRemoteIdentity(schema, { naturalKey: row.identity.naturalKey }) === + canonicalOfflineRemoteIdentity(schema, { naturalKey: normalizeOfflineNaturalKey(schema, identity.naturalKey) }) + ); + }); } #rowForScope( @@ -682,83 +722,155 @@ export class IonicOfflineRepository implements OfflineRepository { } #cursorKey(scope: OfflineScope): string { - return `${scope.userId}:${scope.scopeId}`; + return `${canonicalOfflinePrincipalId(scope.userId)}:${scope.scopeId}`; } - #schemaHasServerId(schema: OfflineReplicaEntitySchema>): boolean { - return schema.identity.kind === 'serverId'; + #compareReplicaIdentity( + schema: OfflineReplicaEntitySchema>, + left: import('./offline-identity').OfflineReplicaIdentity, + right: import('./offline-identity').OfflineReplicaIdentity, + ): number { + if (left.kind === 'natural' && right.kind === 'natural' && schema.identity.kind === 'naturalKey') { + const leftKey = normalizeOfflineNaturalKey(schema, left.naturalKey); + const rightKey = normalizeOfflineNaturalKey(schema, right.naturalKey); + for (const sourceKey of schema.identity.sourceKeys) { + const leftValue = leftKey[sourceKey]!; + const rightValue = rightKey[sourceKey]!; + if (leftValue === rightValue) continue; + if (typeof leftValue === 'number' && typeof rightValue === 'number') { + return leftValue < rightValue ? -1 : 1; + } + return compareUtf8Binary(String(leftValue), String(rightValue)); + } + return 0; + } + const leftId = left.kind === 'natural' ? canonicalOfflineReplicaIdentity(left) : left.localId; + const rightId = right.kind === 'natural' ? canonicalOfflineReplicaIdentity(right) : right.localId; + return compareUtf8Binary(leftId, rightId); } #validateReplicaRow(row: OfflineReplicaRow): void { const schema = this.#resolveReplicaEntitySchema(row.sourceKey); - assertOfflineReplicaServerId(schema, row.serverId); + this.#validateRowIdentity(schema, row); encodeOfflineReplicaValues(schema, row.values); if (row.confirmedValues !== null) encodeOfflineReplicaValues(schema, row.confirmedValues); assertOfflineReplicaNaturalKeyBaseline(schema, row.values, row.confirmedValues); } + #validateRowIdentity(schema: OfflineReplicaEntitySchema>, row: OfflineReplicaRow): void { + if (schema.identity.kind === 'localOnly') { + if (row.identity.kind !== 'local') { + throw new Error(`Offline replica source "${schema.sourceKey}" requires local identity.`); + } + return; + } + if (schema.identity.kind === 'generated') { + if (row.identity.kind !== 'generated') { + throw new Error(`Offline replica source "${schema.sourceKey}" requires generated identity.`); + } + assertOfflineReplicaGeneratedRemoteId(schema, row.identity.remoteId); + return; + } + if (schema.identity.kind === 'naturalKey') { + if (row.identity.kind !== 'natural') { + throw new Error(`Offline replica source "${schema.sourceKey}" requires natural identity.`); + } + const fromValues = offlineNaturalKeyFromValues(schema, row.values)!; + if ( + canonicalOfflineRemoteIdentity(schema, { naturalKey: row.identity.naturalKey }) !== + canonicalOfflineRemoteIdentity(schema, { naturalKey: fromValues }) + ) { + throw new Error(`Offline replica identity naturalKey must match values for "${schema.sourceKey}".`); + } + } + } + #assertUniqueReplicaIdentity(rows: Record, incoming: OfflineReplicaRow): void { const schema = this.#resolveReplicaEntitySchema(incoming.sourceKey); - const identity = this.#rowRemoteIdentity(schema, incoming); + const identity = offlineReplicaRemoteIdentity(schema, incoming.identity); if (identity === null) return; const canonical = canonicalOfflineRemoteIdentity(schema, identity); - const incomingKey = this.#rowKey(incoming, incoming.sourceKey, incoming.localId); + const incomingKey = this.#rowKey(incoming); const collision = Object.entries(rows).find(([key, row]) => { if (key === incomingKey) return false; if (row.userId !== incoming.userId || row.sourceKey !== incoming.sourceKey) return false; if (schema.scope === 'partition' && row.scopeId !== incoming.scopeId) return false; - const rowIdentity = this.#rowRemoteIdentity(schema, row); + const rowIdentity = offlineReplicaRemoteIdentity(schema, row.identity); return rowIdentity !== null && canonicalOfflineRemoteIdentity(schema, rowIdentity) === canonical; }); if (collision) { - if (schema.identity.kind === 'serverId') { - throw new Error(`Offline replica serverId ${String(incoming.serverId)} is already mapped to localId ${collision[1].localId}.`); + if (schema.identity.kind === 'generated') { + const remoteId = incoming.identity.kind === 'generated' ? incoming.identity.remoteId : null; + const mapped = + collision[1].identity.kind === 'generated' + ? collision[1].identity.localId + : canonicalOfflineReplicaIdentity(collision[1].identity); + throw new Error(`Offline replica remote id ${String(remoteId)} is already mapped to ${mapped}.`); } - throw new Error(`Offline replica remote identity is already mapped to localId ${collision[1].localId}.`); + throw new Error(`Offline replica remote identity is already mapped to another row.`); } } #assertReplicaIdentityAssignment( existing: OfflineReplicaRow, incoming: OfflineReplicaRow, - release: OfflineReplicaServerIdRelease | undefined, + release: OfflineReplicaRemoteIdRelease | undefined, ): void { const schema = this.#resolveReplicaEntitySchema(incoming.sourceKey); - if (release && schema.identity.kind !== 'serverId') { - throw new Error(`Offline replica serverId release is unsupported for source "${incoming.sourceKey}".`); + if (release && schema.identity.kind !== 'generated') { + throw new Error(`Offline replica remoteId release is unsupported for source "${incoming.sourceKey}".`); } - if (schema.identity.kind === 'serverId') { + if (schema.identity.kind === 'localOnly') { + if (existing.identity.kind !== 'local' || incoming.identity.kind !== 'local') { + throw new Error(`Offline replica local identity is required for "${incoming.sourceKey}".`); + } + if (existing.identity.localId !== incoming.identity.localId) { + throw new Error(`Offline replica localId is immutable for "${schema.sourceKey}".`); + } + return; + } + if (schema.identity.kind === 'generated') { + if (existing.identity.kind !== 'generated' || incoming.identity.kind !== 'generated') { + throw new Error(`Offline replica generated identity is required for "${incoming.sourceKey}".`); + } if (release) { - if (existing.serverId !== release.serverId || incoming.serverId !== null) { + if (existing.identity.remoteId !== release.remoteId || incoming.identity.remoteId !== null) { throw new Error( - `Offline replica serverId release must transition current=${existing.serverId} to incoming=null for ${incoming.sourceKey}/${incoming.localId}.`, + `Offline replica remoteId release must transition current=${String(existing.identity.remoteId)} to incoming=null for ${incoming.sourceKey}/${incoming.identity.localId}.`, ); } return; } - if (existing.serverId !== null && existing.serverId !== incoming.serverId) { - throw new Error(`Offline replica serverId is immutable: current=${existing.serverId}, incoming=${String(incoming.serverId)}.`); + if (existing.identity.localId !== incoming.identity.localId) { + throw new Error(`Offline replica localId is immutable for "${schema.sourceKey}".`); + } + if (existing.identity.remoteId !== null && existing.identity.remoteId !== incoming.identity.remoteId) { + throw new Error( + `Offline replica remoteId is immutable: current=${String(existing.identity.remoteId)}, incoming=${String(incoming.identity.remoteId)}.`, + ); } return; } if (schema.identity.kind === 'naturalKey') { - const current = canonicalOfflineRemoteIdentity(schema, { - naturalKey: offlineNaturalKeyFromValues(schema, existing.values)!, - }); - const next = canonicalOfflineRemoteIdentity(schema, { - naturalKey: offlineNaturalKeyFromValues(schema, incoming.values)!, - }); + if (existing.identity.kind !== 'natural' || incoming.identity.kind !== 'natural') { + throw new Error(`Offline replica natural identity is required for "${schema.sourceKey}".`); + } + const current = canonicalOfflineRemoteIdentity(schema, { naturalKey: existing.identity.naturalKey }); + const next = canonicalOfflineRemoteIdentity(schema, { naturalKey: incoming.identity.naturalKey }); if (current !== next) throw new Error(`Offline replica naturalKey is immutable for "${schema.sourceKey}".`); } } - #rowRemoteIdentity( - schema: OfflineReplicaEntitySchema>, - row: OfflineReplicaRow, - ): OfflineReplicaRemoteIdentity | null { - if (schema.identity.kind === 'serverId') return row.serverId === null ? null : { serverId: row.serverId }; - if (schema.identity.kind === 'naturalKey') return { naturalKey: offlineNaturalKeyFromValues(schema, row.values)! }; - return null; + #assertValidReleaseRemoteId(remoteId: OfflineGeneratedRemoteId): void { + if (typeof remoteId === 'number') { + if (!Number.isSafeInteger(remoteId) || remoteId <= 0) { + throw new Error(`Offline replica release has invalid remoteId ${String(remoteId)}.`); + } + return; + } + if (typeof remoteId !== 'string' || remoteId.length === 0) { + throw new Error(`Offline replica release has invalid remoteId ${String(remoteId)}.`); + } } #resolveReplicaEntitySchema(sourceKey: string): OfflineReplicaEntitySchema> { @@ -767,3 +879,13 @@ export class IonicOfflineRepository implements OfflineRepository { return schema; } } + +function compareUtf8Binary(left: string, right: string): number { + const leftBytes = new TextEncoder().encode(left); + const rightBytes = new TextEncoder().encode(right); + const length = Math.min(leftBytes.length, rightBytes.length); + for (let index = 0; index < length; index += 1) { + if (leftBytes[index] !== rightBytes[index]) return leftBytes[index]! < rightBytes[index]! ? -1 : 1; + } + return leftBytes.length < rightBytes.length ? -1 : leftBytes.length > rightBytes.length ? 1 : 0; +} diff --git a/projects/kit/offline/src/lib/offline-session.service.ts b/projects/kit/offline/src/lib/offline-session.service.ts index be3f488..02a53eb 100644 --- a/projects/kit/offline/src/lib/offline-session.service.ts +++ b/projects/kit/offline/src/lib/offline-session.service.ts @@ -1,10 +1,11 @@ import { inject, Injectable, signal } from '@angular/core'; import type { OfflineScope } from './offline-repository'; +import type { OfflinePrincipalId } from './offline-identity'; import { OFFLINE_REPOSITORY } from './offline-repository'; /** Persisted identity and partition boundary for one authenticated local replica. */ export interface OfflineSessionManifest { - userId: number; + userId: OfflinePrincipalId; readonly scopeIds: readonly string[]; /** Authentication-provider subject used to distinguish users on a shared device. */ authSubject: string | null; @@ -38,15 +39,15 @@ export class OfflineSessionService { this.#initialized = true; } - async activateSession(userId: number, scopeIds: readonly string[], authSubject: string | null): Promise; + async activateSession(userId: OfflinePrincipalId, scopeIds: readonly string[], authSubject: string | null): Promise; async activateSession( - userId: number, + userId: OfflinePrincipalId, scopeIds: readonly string[], authSubject: string | null, lease: OfflineSessionTransitionLease, ): Promise; async activateSession( - userId: number, + userId: OfflinePrincipalId, scopeIds: readonly string[], authSubject: string | null, lease?: OfflineSessionTransitionLease, @@ -149,18 +150,18 @@ export class OfflineSessionService { } /** Returns the session allowed to use the local replica and append outbox commands. */ - async getLocalSession(): Promise<{ userId: number; scopes: OfflineScope[] } | null> { + async getLocalSession(): Promise<{ userId: OfflinePrincipalId; scopes: OfflineScope[] } | null> { await this.initialize(); return this.#localAccessThisRun ? this.#sessionFromManifest() : null; } /** Returns the remotely authenticated session eligible for pull and command replay. */ - async getSession(): Promise<{ userId: number; scopes: OfflineScope[] } | null> { + async getSession(): Promise<{ userId: OfflinePrincipalId; scopes: OfflineScope[] } | null> { await this.initialize(); return this.#remoteActivatedThisRun ? this.#sessionFromManifest() : null; } - #sessionFromManifest(): { userId: number; scopes: OfflineScope[] } | null { + #sessionFromManifest(): { userId: OfflinePrincipalId; scopes: OfflineScope[] } | null { const manifest = this.#activeManifest(); return manifest ? { userId: manifest.userId, scopes: manifest.scopeIds.map((scopeId) => ({ userId: manifest.userId, scopeId })) } 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 9ae882d..08b92ff 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.spec.ts @@ -10,14 +10,18 @@ import { import { OFFLINE_KIT_OPTIONS, type OfflineKitOptions } from './offline-kit-options'; import { OfflineNetworkService } from './offline-network.service'; import { OfflineReplicaPullService } from './offline-replica-pull.service'; -import { defineOfflineReplicaSchema, defineReplicaEntity, integer, naturalKey, serverId, text } from './offline-replica-schema'; +import { defineOfflineReplicaSchema, defineReplicaEntity, integer, naturalKey, generatedId, text } from './offline-replica-schema'; import { + canonicalOfflineReplicaIdentity, OFFLINE_REPOSITORY, type OfflineCommand, + type OfflineCommandIdentity, + type OfflineReplicaAddress, type OfflineReplicaRow, type OfflineRepository, type OfflineScope, } from './offline-repository'; +import { generatedCommandIdentity } from './offline-test-helpers'; import { OfflinePayloadValidationError, OfflineSyncService } from './offline-sync.service'; const replicaSchema = defineOfflineReplicaSchema({ @@ -28,7 +32,7 @@ const replicaSchema = defineOfflineReplicaSchema({ sourceKey: 'documents', scope: 'partition', fields: { - id: serverId(), + id: generatedId('integer'), title: text(), }, }), @@ -50,6 +54,19 @@ const naturalReplicaSchema = defineOfflineReplicaSchema({ migrations: [], }); +const textReplicaSchema = defineOfflineReplicaSchema({ + version: 1, + entities: [ + defineReplicaEntity<{ id: string; title: string }>()({ + table: 'text_documents', + sourceKey: 'text_documents', + scope: 'partition', + fields: { id: generatedId('text'), title: text() }, + }), + ], + migrations: [], +}); + describe('OfflineSyncService', () => { let service: OfflineSyncService; let commands: OfflineCommand[]; @@ -99,29 +116,39 @@ describe('OfflineSyncService', () => { removeCommand: vi.fn(async (commandId: string) => { commands = commands.filter((item) => item.commandId !== commandId); }), - getReplicaRow: vi.fn(async (scope: OfflineScope, sourceKey: string, localId: string) => { + getReplicaRow: vi.fn(async (scope: OfflineScope, sourceKey: string, identity: OfflineCommandIdentity) => { await beforeGetReplicaRow?.(); return ( - rows.find( - (item) => - item.userId === scope.userId && item.scopeId === scope.scopeId && item.sourceKey === sourceKey && item.localId === localId, - ) ?? null + rows.find((item) => { + if (item.userId !== scope.userId || item.scopeId !== scope.scopeId || item.sourceKey !== sourceKey) return false; + if (identity.kind === 'generated') { + return item.identity.kind === 'generated' && item.identity.localId === identity.localId; + } + return item.identity.kind === 'natural' && JSON.stringify(item.identity.naturalKey) === JSON.stringify(identity.naturalKey); + }) ?? null ); }), - getReplicaRowIncludingPendingDelete: vi.fn(async (scope: OfflineScope, sourceKey: string, localId: string) => { + getReplicaRowIncludingPendingDelete: vi.fn(async (scope: OfflineScope, sourceKey: string, identity: OfflineCommandIdentity) => { await beforeGetReplicaRow?.(); return ( - rows.find( - (item) => - item.userId === scope.userId && item.scopeId === scope.scopeId && item.sourceKey === sourceKey && item.localId === localId, - ) ?? null + rows.find((item) => { + if (item.userId !== scope.userId || item.scopeId !== scope.scopeId || item.sourceKey !== sourceKey) return false; + if (identity.kind === 'generated') { + return item.identity.kind === 'generated' && item.identity.localId === identity.localId; + } + return item.identity.kind === 'natural' && JSON.stringify(item.identity.naturalKey) === JSON.stringify(identity.naturalKey); + }) ?? null ); }), - getReplicaRowByServerId: vi.fn( - async (scope: OfflineScope, sourceKey: string, serverId: number) => + getReplicaRowByRemoteId: vi.fn( + async (scope: OfflineScope, sourceKey: string, remoteId: number) => rows.find( (item) => - item.userId === scope.userId && item.scopeId === scope.scopeId && item.sourceKey === sourceKey && item.serverId === serverId, + item.userId === scope.userId && + item.scopeId === scope.scopeId && + item.sourceKey === sourceKey && + item.identity.kind === 'generated' && + item.identity.remoteId === remoteId, ) ?? null, ), getReplicaRowByRemoteIdentity: vi.fn(async (scope: OfflineScope, sourceKey: string, identity) => { @@ -142,7 +169,8 @@ describe('OfflineSyncService', () => { item.userId === scope.userId && item.scopeId === scope.scopeId && item.sourceKey === sourceKey && - item.serverId === identity.serverId, + item.identity.kind === 'generated' && + item.identity.remoteId === identity.remoteId, ) ?? null ); }), @@ -154,7 +182,7 @@ describe('OfflineSyncService', () => { item.userId !== row.userId || item.scopeId !== row.scopeId || item.sourceKey !== row.sourceKey || - item.localId !== row.localId, + canonicalOfflineReplicaIdentity(item.identity) !== canonicalOfflineReplicaIdentity(row.identity), ); rows.push(structuredClone(row)); } @@ -164,7 +192,7 @@ describe('OfflineSyncService', () => { item.userId !== key.userId || item.scopeId !== key.scopeId || item.sourceKey !== key.sourceKey || - item.localId !== key.localId, + canonicalOfflineReplicaIdentity(item.identity) !== canonicalOfflineReplicaIdentity(key.identity), ); } for (const command of transaction.putCommands ?? []) { @@ -209,7 +237,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: 'first', + identity: { kind: 'generated', localId: 'first' }, operation: 'documents.create', payload: { title: 'first' }, optimisticValue: { id: 0, title: 'first' }, @@ -222,7 +250,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: 'second', + identity: { kind: 'generated', localId: 'second' }, operation: 'documents.create', payload: { title: 'second' }, optimisticValue: { id: 0, title: 'second' }, @@ -231,7 +259,7 @@ describe('OfflineSyncService', () => { ), ).rejects.toMatchObject({ name: 'OfflineOutboxCapacityError', reason: 'command_count' }); expect(commands).toHaveLength(1); - expect(rows.map((row) => row.localId)).toEqual(['first']); + expect(rows.map((row) => (row.identity.kind === 'generated' ? row.identity.localId : ''))).toEqual(['first']); }); it('Outbox容量上限では既存commandとreplicaを失わず新規enqueueを拒否する', async () => { @@ -242,7 +270,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: 'oversized', + identity: { kind: 'generated', localId: 'oversized' }, operation: 'documents.create', payload: { title: 'too large' }, optimisticValue: { id: 0, title: 'too large' }, @@ -263,7 +291,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: 'offline-local', + identity: { kind: 'generated', localId: 'offline-local' }, operation: 'documents.create', payload: { title: 'offline' }, optimisticValue: { id: 0, title: 'offline' }, @@ -278,13 +306,20 @@ describe('OfflineSyncService', () => { expect(service.pendingCount()).toBe(1); }); + it('session principalと型まで一致しないscopeをactivation前に拒否する', async () => { + localSession = { userId: 7, scopes: [{ userId: '7', scopeId: '10' }] }; + + await expect(service.refreshLocalSession()).rejects.toThrow('Offline sync session scope belongs to a different principal.'); + expect(service.pendingCount()).toBe(0); + }); + it('offline初期化後の再接続でpending outboxを自動送信する', async () => { await service.initialize(); await service.enqueue( { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: 'reconnect-local', + identity: { kind: 'generated', localId: 'reconnect-local' }, operation: 'documents.create', payload: { title: 'queued offline' }, optimisticValue: { id: 0, title: 'queued offline' }, @@ -317,7 +352,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: 'revoked', + identity: { kind: 'generated', localId: 'revoked' }, operation: 'documents.create', payload: { title: 'stale' }, optimisticValue: { id: 0, title: 'stale' }, @@ -363,7 +398,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '1', + identity: { kind: 'generated', localId: '1' }, operation: 'documents.upsert', payload: { seq: 1 }, optimisticValue: { seq: 1 }, @@ -374,7 +409,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '1', + identity: { kind: 'generated', localId: '1' }, operation: 'documents.upsert', payload: { seq: 2 }, optimisticValue: { seq: 2 }, @@ -389,7 +424,7 @@ describe('OfflineSyncService', () => { it('local_idを不変主キーにして送信直前に最新server_idへ解決する', async () => { execute.mockResolvedValueOnce({ - serverId: 38142, + remoteId: 38142, serverRevision: 1, confirmedValues: { name: 'draft' }, response: { id: 38142 }, @@ -398,28 +433,27 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '019d-aaaa', + identity: { kind: 'generated', localId: '019d-aaaa' }, operation: 'documents.create', payload: { name: 'draft' }, optimisticValue: { name: 'draft' }, }, { flush: false }, ); - expect(rows[0]).toMatchObject({ localId: '019d-aaaa', serverId: null, syncState: 'pending' }); - expect(commands[0]).toMatchObject({ aggregateLocalId: '019d-aaaa' }); - expect('serverId' in commands[0]!).toBe(false); + expect(rows[0]).toMatchObject({ identity: { kind: 'generated', localId: '019d-aaaa', remoteId: null }, syncState: 'pending' }); + expect(commands[0]).toMatchObject({ identity: { kind: 'generated', localId: '019d-aaaa' } }); + expect('remoteId' in commands[0]!).toBe(false); connected.set(true); await service.flush(); - expect(execute.mock.calls[0]?.[1]).toEqual({ localId: '019d-aaaa', serverId: null }); + expect(execute.mock.calls[0]?.[1]).toEqual({ kind: 'generated', localId: '019d-aaaa', remoteId: null }); expect(rows[0]).toMatchObject({ - localId: '019d-aaaa', - serverId: 38142, + identity: { kind: 'generated', localId: '019d-aaaa', remoteId: 38142 }, serverRevision: 1, syncState: 'confirmed', confirmedValues: { name: 'draft' }, }); - expect(commands.every((command) => !('serverId' in command))).toBe(true); + expect(commands.every((command) => !('remoteId' in command))).toBe(true); execute.mockResolvedValueOnce({ serverRevision: 2, @@ -430,7 +464,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '019d-aaaa', + identity: { kind: 'generated', localId: '019d-aaaa' }, operation: 'documents.update', payload: { name: 'edited', revision: 1 }, optimisticValue: { name: 'edited' }, @@ -439,14 +473,13 @@ describe('OfflineSyncService', () => { { flush: false }, ); await service.flush(); - expect(execute.mock.calls[1]?.[1]).toEqual({ localId: '019d-aaaa', serverId: 38142 }); + expect(execute.mock.calls[1]?.[1]).toEqual({ kind: 'generated', localId: '019d-aaaa', remoteId: 38142 }); expect(rows[0]).toMatchObject({ - localId: '019d-aaaa', - serverId: 38142, + identity: { kind: 'generated', localId: '019d-aaaa', remoteId: 38142 }, serverRevision: 2, confirmedValues: { name: 'edited' }, }); - expect(commands.every((command) => !('serverId' in command))).toBe(true); + expect(commands.every((command) => !('remoteId' in command))).toBe(true); }); it('session scope発見後に前回起動のsending commandをpendingへ復旧する', async () => { @@ -455,8 +488,7 @@ describe('OfflineSyncService', () => { userId: 1, scopeId: '10', sourceKey: 'documents', - localId: '019d-aaaa', - serverId: null, + identity: { kind: 'generated', localId: '019d-aaaa', remoteId: null }, values: {}, confirmedValues: null, serverRevision: null, @@ -468,7 +500,8 @@ describe('OfflineSyncService', () => { scopeId: '10', commandId: 'interrupted', aggregateType: 'documents', - aggregateLocalId: '019d-aaaa', + sourceKey: 'documents', + identity: { kind: 'generated', localId: '019d-aaaa' }, operation: 'documents.create', payload: {}, optimisticValue: {}, @@ -491,7 +524,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '019d-new', + identity: { kind: 'generated', localId: '019d-new' }, operation: 'documents.create', payload: { name: 'draft' }, optimisticValue: { name: 'draft' }, @@ -508,8 +541,7 @@ describe('OfflineSyncService', () => { userId: 1, scopeId: '10', sourceKey: 'documents', - localId: '019d-existing', - serverId: 38142, + identity: { kind: 'generated', localId: '019d-existing', remoteId: 38142 }, values: { name: 'confirmed' }, confirmedValues: { name: 'confirmed' }, serverRevision: 4, @@ -520,7 +552,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '019d-existing', + identity: { kind: 'generated', localId: '019d-existing' }, operation: 'documents.update', payload: { name: 'draft', revision: 4 }, optimisticValue: { name: 'draft' }, @@ -534,7 +566,7 @@ describe('OfflineSyncService', () => { values: { name: 'confirmed' }, confirmedValues: { name: 'confirmed' }, syncState: 'confirmed', - serverId: 38142, + identity: expect.objectContaining({ remoteId: 38142 }), }); }); @@ -544,7 +576,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '1', + identity: { kind: 'generated', localId: '1' }, operation: 'documents.upsert', payload: { seq: 1 }, optimisticValue: { seq: 1 }, @@ -555,7 +587,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '2', + identity: { kind: 'generated', localId: '2' }, operation: 'documents.upsert', payload: { seq: 2 }, optimisticValue: { seq: 2 }, @@ -573,7 +605,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '1', + identity: { kind: 'generated', localId: '1' }, operation: 'documents.upsert', payload: { あ: 3, z: 1, ä: 2 }, optimisticValue: {}, @@ -584,7 +616,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '2', + identity: { kind: 'generated', localId: '2' }, operation: 'documents.upsert', payload: { ä: 2, あ: 3, z: 1 }, optimisticValue: {}, @@ -600,7 +632,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '1', + identity: { kind: 'generated', localId: '1' }, operation: 'documents.upsert', payload: { value: undefined }, optimisticValue: {}, @@ -618,7 +650,14 @@ describe('OfflineSyncService', () => { ] as const)('HTTP %sを%sへ分類して操作を保持する', async (status, state, rowSyncState) => { execute.mockRejectedValueOnce({ status }); await service.enqueue( - { scopeId: '10', aggregateType: 'documents', aggregateLocalId: '1', operation: 'documents.upsert', payload: {}, optimisticValue: {} }, + { + scopeId: '10', + aggregateType: 'documents', + identity: { kind: 'generated', localId: '1' }, + operation: 'documents.upsert', + payload: {}, + optimisticValue: {}, + }, { flush: false }, ); connected.set(true); @@ -637,8 +676,7 @@ describe('OfflineSyncService', () => { userId: 1, scopeId: '10', sourceKey: 'documents', - localId: `delete-${status}`, - serverId: 42, + identity: { kind: 'generated', localId: `delete-${status}`, remoteId: 42 }, values: { name: 'confirmed' }, confirmedValues: { name: 'confirmed' }, serverRevision: 1, @@ -651,7 +689,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: `delete-${status}`, + identity: generatedCommandIdentity(`delete-${status}`), operation: 'documents.delete', payload: {}, optimisticValue: { name: 'confirmed' }, @@ -672,7 +710,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '1', + identity: { kind: 'generated', localId: '1' }, operation: 'documents.upsert', payload: { seq: 1 }, optimisticValue: { seq: 1 }, @@ -683,7 +721,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '1', + identity: { kind: 'generated', localId: '1' }, operation: 'documents.upsert', payload: { seq: 2 }, optimisticValue: { seq: 2 }, @@ -708,7 +746,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '1', + identity: { kind: 'generated', localId: '1' }, operation: 'documents.upsert', payload: { seq: 1 }, optimisticValue: { seq: 1 }, @@ -719,7 +757,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '1', + identity: { kind: 'generated', localId: '1' }, operation: 'documents.upsert', payload: { seq: 2 }, optimisticValue: { seq: 2 }, @@ -748,7 +786,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '1', + identity: { kind: 'generated', localId: '1' }, operation: 'documents.upsert', payload: { seq: 1 }, optimisticValue: { seq: 1 }, @@ -794,7 +832,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '1', + identity: { kind: 'generated', localId: '1' }, operation: 'documents.upsert', payload: { seq: 1 }, optimisticValue: { seq: 1 }, @@ -825,7 +863,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '1', + identity: { kind: 'generated', localId: '1' }, operation: 'documents.upsert', payload: {}, optimisticValue: {}, @@ -837,7 +875,9 @@ describe('OfflineSyncService', () => { connected.set(true); await service.refreshSession(); await vi.waitFor(() => - expect(handleError).toHaveBeenCalledWith(expect.objectContaining({ message: 'Offline replica row not found: documents/1' })), + expect(handleError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Offline replica row not found: documents/generated:1' }), + ), ); await service.refreshSession(); @@ -858,7 +898,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '1', + identity: { kind: 'generated', localId: '1' }, operation: 'documents.upsert', payload: {}, optimisticValue: {}, @@ -879,7 +919,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '1', + identity: { kind: 'generated', localId: '1' }, operation: 'documents.upsert', payload: {}, optimisticValue: {}, @@ -898,7 +938,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '1', + identity: { kind: 'generated', localId: '1' }, operation: 'documents.upsert', payload: {}, optimisticValue: {}, @@ -910,13 +950,13 @@ describe('OfflineSyncService', () => { expect(service.pendingCommands()[0]?.state).toBe('sending'); }); - it('invalid serverIdはhard failする', async () => { - execute.mockResolvedValueOnce({ serverId: 0, serverRevision: 1, confirmedValues: {}, response: null }); + it('invalid remoteIdはhard failする', async () => { + execute.mockResolvedValueOnce({ remoteId: 0, serverRevision: 1, confirmedValues: {}, response: null }); await service.enqueue( { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '019d-invalid-id', + identity: { kind: 'generated', localId: '019d-invalid-id' }, operation: 'documents.create', payload: {}, optimisticValue: {}, @@ -924,13 +964,13 @@ describe('OfflineSyncService', () => { { flush: false }, ); connected.set(true); - await expect(service.flush()).rejects.toThrow('Offline command returned invalid serverId 0.'); + await expect(service.flush()).rejects.toThrow('Offline replica generated remote id must be a positive integer.'); }); - it('naturalKey entityへのserverId応答はcompleteCommand境界でhard failする', async () => { + it('naturalKey entityへのremoteId応答はcompleteCommand境界でhard failする', async () => { options.replicaSchema = naturalReplicaSchema; execute.mockResolvedValueOnce({ - serverId: 99, + remoteId: 99, serverRevision: 1, confirmedValues: { favFrom: 7, favTo: '42', title: 'confirmed' }, response: null, @@ -939,7 +979,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'natural_documents', - aggregateLocalId: 'immutable-uuid', + identity: { kind: 'natural', naturalKey: { favFrom: 7, favTo: '42' } }, operation: 'natural_documents.create', payload: {}, optimisticValue: { favFrom: 7, favTo: '42', title: 'local' }, @@ -947,46 +987,84 @@ describe('OfflineSyncService', () => { { flush: false }, ); connected.set(true); - await expect(service.flush()).rejects.toThrow('Offline command returned serverId for naturalKey source "natural_documents".'); + await expect(service.flush()).rejects.toThrow( + 'Offline command returned generated remote id for naturalKey source "natural_documents".', + ); }); - it('naturalKey entityへのserverIdHint指定はenqueue境界でhard failする', async () => { + it('naturalKey entityへgenerated identityを指定するとenqueue境界でhard failする', async () => { options.replicaSchema = naturalReplicaSchema; await expect( service.enqueue( { scopeId: '10', aggregateType: 'natural_documents', - aggregateLocalId: 'immutable-uuid', - serverIdHint: 99, + identity: { kind: 'generated', localId: 'immutable-uuid', remoteIdHint: 99 }, operation: 'natural_documents.create', payload: {}, optimisticValue: { favFrom: 7, favTo: '42', title: 'local' }, }, { flush: false }, ), - ).rejects.toThrow('Offline replica source "natural_documents" does not define a serverId field.'); + ).rejects.toThrow('Offline replica source "natural_documents" requires natural identity.'); + }); + + it('natural identityとoptimistic valueのkey不一致を永続化前に拒否する', async () => { + options.replicaSchema = naturalReplicaSchema; + + await expect( + service.enqueue( + { + scopeId: '10', + aggregateType: 'natural_documents', + identity: { kind: 'natural', naturalKey: { favFrom: 7, favTo: '22' } }, + operation: 'natural_documents.create', + payload: {}, + optimisticValue: { favFrom: 7, favTo: '21', title: 'local' }, + }, + { flush: false }, + ), + ).rejects.toThrow('Offline command naturalKey must match optimistic values for "natural_documents".'); + expect(commands).toEqual([]); + expect(rows).toEqual([]); + }); + + it('empty generated localIdを永続化前に拒否する', async () => { + await expect( + service.enqueue( + { + scopeId: '10', + aggregateType: 'documents', + identity: { kind: 'generated', localId: '' }, + operation: 'documents.create', + payload: {}, + optimisticValue: { id: 0, title: 'local' }, + }, + { flush: false }, + ), + ).rejects.toThrow('Offline localId must be a non-empty normalized string'); + expect(commands).toEqual([]); + expect(rows).toEqual([]); }); - it('reassigned serverIdはhard failする', async () => { + it('reassigned remoteIdはhard failする', async () => { rows.push({ userId: 1, scopeId: '10', sourceKey: 'documents', - localId: '019d-existing', - serverId: 38142, + identity: { kind: 'generated', localId: '019d-existing', remoteId: 38142 }, values: {}, confirmedValues: {}, serverRevision: 1, fetchedAt: 1, syncState: 'confirmed', }); - execute.mockResolvedValueOnce({ serverId: 99999, serverRevision: 2, confirmedValues: {}, response: null }); + execute.mockResolvedValueOnce({ remoteId: 99999, serverRevision: 2, confirmedValues: {}, response: null }); await service.enqueue( { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '019d-existing', + identity: { kind: 'generated', localId: '019d-existing' }, operation: 'documents.update', payload: {}, optimisticValue: {}, @@ -995,16 +1073,15 @@ describe('OfflineSyncService', () => { { flush: false }, ); connected.set(true); - await expect(service.flush()).rejects.toThrow('Offline replica serverId is immutable'); + await expect(service.flush()).rejects.toThrow('Offline replica remote id is immutable'); }); - it('enqueue時のserverId採用は初回pull前にreplica rowへ永続化する', async () => { + it('enqueue時のremoteId採用は初回pull前にreplica rowへ永続化する', async () => { await service.enqueue( { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '019d-adopted', - serverId: 38142, + identity: { kind: 'generated', localId: '019d-adopted', remoteId: 38142 }, operation: 'documents.update', payload: { name: 'adopted' }, optimisticValue: { name: 'adopted' }, @@ -1012,20 +1089,18 @@ describe('OfflineSyncService', () => { { flush: false }, ); expect(rows[0]).toMatchObject({ - localId: '019d-adopted', - serverId: 38142, + identity: { kind: 'generated', localId: '019d-adopted', remoteId: 38142 }, confirmedValues: null, syncState: 'pending', }); }); - it('採用済みserverIdはflush時にdelete操作のexecutor targetへ渡す', async () => { + it('採用済みremoteIdはflush時にdelete操作のexecutor targetへ渡す', async () => { await service.enqueue( { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '019d-adopted', - serverId: 38142, + identity: { kind: 'generated', localId: '019d-adopted', remoteId: 38142 }, operation: 'documents.delete', payload: {}, optimisticValue: {}, @@ -1035,7 +1110,7 @@ describe('OfflineSyncService', () => { connected.set(true); execute.mockResolvedValueOnce({ removeReplica: true, response: null }); await service.flush(); - expect(execute.mock.calls[0]?.[1]).toEqual({ localId: '019d-adopted', serverId: 38142 }); + expect(execute.mock.calls[0]?.[1]).toEqual({ kind: 'generated', localId: '019d-adopted', remoteId: 38142 }); }); it('confirmed rowのdeleteはOutbox markerとhidden baselineを原子的に残し、ACKでphysical removeする', async () => { @@ -1043,8 +1118,7 @@ describe('OfflineSyncService', () => { userId: 1, scopeId: '10', sourceKey: 'documents', - localId: '019d-delete', - serverId: 38142, + identity: { kind: 'generated', localId: '019d-delete', remoteId: 38142 }, values: { name: 'confirmed' }, confirmedValues: { name: 'confirmed' }, serverRevision: 4, @@ -1057,7 +1131,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '019d-delete', + identity: { kind: 'generated', localId: '019d-delete' }, operation: 'documents.delete', payload: { id: 38142 }, optimisticValue: { name: 'confirmed' }, @@ -1068,7 +1142,11 @@ describe('OfflineSyncService', () => { ); expect(rows[0]).toMatchObject({ visibility: 'pending_delete', confirmedValues: { name: 'confirmed' }, serverRevision: 4 }); - expect(commands[0]).toMatchObject({ replicaMutation: 'delete', aggregateLocalId: '019d-delete', baseRevision: 4 }); + expect(commands[0]).toMatchObject({ + replicaMutation: 'delete', + identity: { kind: 'generated', localId: '019d-delete' }, + baseRevision: 4, + }); execute.mockResolvedValueOnce({ removeReplica: true, response: null }); connected.set(true); @@ -1082,8 +1160,7 @@ describe('OfflineSyncService', () => { userId: 1, scopeId: '10', sourceKey: 'documents', - localId: '019d-delete-without-projection', - serverId: 38142, + identity: { kind: 'generated', localId: '019d-delete-without-projection', remoteId: 38142 }, values: { name: 'confirmed' }, confirmedValues: { name: 'confirmed' }, serverRevision: 4, @@ -1095,7 +1172,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '019d-delete-without-projection', + identity: { kind: 'generated', localId: '019d-delete-without-projection' }, operation: 'documents.delete', payload: { id: 38142 }, optimisticValue: { name: 'confirmed' }, @@ -1113,13 +1190,12 @@ describe('OfflineSyncService', () => { expect(commands).toEqual([]); }); - it('delete後のqueued recreateはlocalIdを維持してserverIdを再割当できる', async () => { + it('delete後のqueued recreateはlocalIdを維持してremoteIdを再割当できる', async () => { rows.push({ userId: 1, scopeId: '10', sourceKey: 'documents', - localId: 'stable-local-id', - serverId: 42, + identity: { kind: 'generated', localId: 'stable-local-id', remoteId: 42 }, values: { name: 'confirmed', presentation: 'pending' }, confirmedValues: { name: 'confirmed', presentation: 'pending' }, serverRevision: 4, @@ -1131,7 +1207,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: 'stable-local-id', + identity: { kind: 'generated', localId: 'stable-local-id' }, operation: 'documents.delete', payload: {}, optimisticValue: rows[0]!.values, @@ -1143,7 +1219,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: 'stable-local-id', + identity: { kind: 'generated', localId: 'stable-local-id' }, operation: 'documents.create', payload: {}, optimisticValue: { name: 'recreated', presentation: 'pending' }, @@ -1153,14 +1229,13 @@ describe('OfflineSyncService', () => { // A feed/cache integration may patch local-only values while delete is in flight. rows[0] = { ...rows[0]!, values: { name: 'recreated', presentation: null } }; - execute.mockResolvedValueOnce({ removeReplica: true, clearServerId: true, response: null }).mockImplementationOnce(async () => { + execute.mockResolvedValueOnce({ removeReplica: true, clearRemoteId: true, response: null }).mockImplementationOnce(async () => { expect(rows[0]).toMatchObject({ - localId: 'stable-local-id', - serverId: null, + identity: { kind: 'generated', localId: 'stable-local-id', remoteId: null }, values: { name: 'recreated', presentation: null }, }); return { - serverId: 43, + remoteId: 43, confirmedValues: { name: 'recreated', presentation: null }, response: null, }; @@ -1170,13 +1245,12 @@ describe('OfflineSyncService', () => { await service.flush(); expect(execute.mock.calls.map((call) => call[1])).toEqual([ - { localId: 'stable-local-id', serverId: 42 }, - { localId: 'stable-local-id', serverId: null }, + { kind: 'generated', localId: 'stable-local-id', remoteId: 42 }, + { kind: 'generated', localId: 'stable-local-id', remoteId: null }, ]); expect(rows).toEqual([ expect.objectContaining({ - localId: 'stable-local-id', - serverId: 43, + identity: { kind: 'generated', localId: 'stable-local-id', remoteId: 43 }, serverRevision: null, values: { name: 'recreated', presentation: null }, syncState: 'confirmed', @@ -1190,8 +1264,7 @@ describe('OfflineSyncService', () => { userId: 1, scopeId: '10', sourceKey: 'documents', - localId: 'race-local-id', - serverId: 42, + identity: { kind: 'generated', localId: 'race-local-id', remoteId: 42 }, values: { name: 'confirmed' }, confirmedValues: { name: 'confirmed' }, serverRevision: 4, @@ -1202,12 +1275,12 @@ describe('OfflineSyncService', () => { let resolveDelete!: (result: OfflineCommandResult) => void; execute .mockImplementationOnce(() => new Promise((resolve) => (resolveDelete = resolve))) - .mockResolvedValueOnce({ serverId: 43, confirmedValues: { name: 'recreated' }, response: null }); + .mockResolvedValueOnce({ remoteId: 43, confirmedValues: { name: 'recreated' }, response: null }); await service.enqueue( { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: 'race-local-id', + identity: { kind: 'generated', localId: 'race-local-id' }, operation: 'documents.delete', payload: {}, optimisticValue: { name: 'confirmed' }, @@ -1223,24 +1296,23 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: 'race-local-id', + identity: { kind: 'generated', localId: 'race-local-id' }, operation: 'documents.create', payload: {}, optimisticValue: { name: 'recreated' }, }, { flush: false }, ); - resolveDelete({ removeReplica: true, clearServerId: true, response: null }); + resolveDelete({ removeReplica: true, clearRemoteId: true, response: null }); await flush; expect(execute.mock.calls.map((call) => call[1])).toEqual([ - { localId: 'race-local-id', serverId: 42 }, - { localId: 'race-local-id', serverId: null }, + { kind: 'generated', localId: 'race-local-id', remoteId: 42 }, + { kind: 'generated', localId: 'race-local-id', remoteId: null }, ]); expect(rows).toEqual([ expect.objectContaining({ - localId: 'race-local-id', - serverId: 43, + identity: { kind: 'generated', localId: 'race-local-id', remoteId: 43 }, values: { name: 'recreated' }, syncState: 'confirmed', }), @@ -1253,8 +1325,7 @@ describe('OfflineSyncService', () => { userId: 1, scopeId: '10', sourceKey: 'documents', - localId: 'serialized-cache-local-id', - serverId: 42, + identity: { kind: 'generated', localId: 'serialized-cache-local-id', remoteId: 42 }, values: { name: 'confirmed', presentation: 'pending' }, confirmedValues: { name: 'confirmed', presentation: 'pending' }, serverRevision: 4, @@ -1268,12 +1339,12 @@ describe('OfflineSyncService', () => { const ackReadStarted = vi.fn(); execute .mockImplementationOnce(() => new Promise((resolve) => (resolveDelete = resolve))) - .mockResolvedValueOnce({ serverId: 43, confirmedValues: { name: 'recreated', presentation: null }, response: null }); + .mockResolvedValueOnce({ remoteId: 43, confirmedValues: { name: 'recreated', presentation: null }, response: null }); await service.enqueue( { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: 'serialized-cache-local-id', + identity: { kind: 'generated', localId: 'serialized-cache-local-id' }, operation: 'documents.delete', payload: {}, optimisticValue: { name: 'confirmed', presentation: 'pending' }, @@ -1288,7 +1359,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: 'serialized-cache-local-id', + identity: { kind: 'generated', localId: 'serialized-cache-local-id' }, operation: 'documents.create', payload: {}, optimisticValue: { name: 'recreated', presentation: 'pending' }, @@ -1299,16 +1370,18 @@ describe('OfflineSyncService', () => { ackReadStarted(); await ackReadGate; }; - resolveDelete({ removeReplica: true, clearServerId: true, response: null }); + resolveDelete({ removeReplica: true, clearRemoteId: true, response: null }); await vi.waitFor(() => expect(ackReadStarted).toHaveBeenCalledOnce()); const cacheProjection = service.runSerializedReplicaMutation(async (repository) => { - const current = await repository.getReplicaRowIncludingPendingDelete!( - { userId: 1, scopeId: '10' }, - 'documents', - 'serialized-cache-local-id', - ); - expect(current).toMatchObject({ serverId: null, values: { name: 'recreated', presentation: 'pending' } }); + const current = await repository.getReplicaRowIncludingPendingDelete!({ userId: 1, scopeId: '10' }, 'documents', { + kind: 'generated', + localId: 'serialized-cache-local-id', + }); + expect(current).toMatchObject({ + identity: { kind: 'generated', remoteId: null }, + values: { name: 'recreated', presentation: 'pending' }, + }); await repository.transactReplica({ putRows: [{ ...current!, values: { name: 'recreated', presentation: null } }], }); @@ -1318,20 +1391,18 @@ describe('OfflineSyncService', () => { expect(rows).toEqual([ expect.objectContaining({ - localId: 'serialized-cache-local-id', - serverId: 43, + identity: { kind: 'generated', localId: 'serialized-cache-local-id', remoteId: 43 }, values: { name: 'recreated', presentation: null }, }), ]); }); - it('delete ACK後のstale serverIdHintは採用せずrecreateをserverId nullから開始する', async () => { + it('delete ACK後のstale remoteIdHintは採用せずrecreateをremoteId nullから開始する', async () => { rows.push({ userId: 1, scopeId: '10', sourceKey: 'documents', - localId: 'complete-first-local-id', - serverId: 42, + identity: { kind: 'generated', localId: 'complete-first-local-id', remoteId: 42 }, values: { name: 'confirmed' }, confirmedValues: { name: 'confirmed' }, serverRevision: 4, @@ -1340,13 +1411,13 @@ describe('OfflineSyncService', () => { visibility: 'present', }); execute - .mockResolvedValueOnce({ removeReplica: true, clearServerId: true, response: null }) - .mockResolvedValueOnce({ serverId: 43, confirmedValues: { name: 'recreated' }, response: null }); + .mockResolvedValueOnce({ removeReplica: true, clearRemoteId: true, response: null }) + .mockResolvedValueOnce({ remoteId: 43, confirmedValues: { name: 'recreated' }, response: null }); await service.enqueue( { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: 'complete-first-local-id', + identity: { kind: 'generated', localId: 'complete-first-local-id' }, operation: 'documents.delete', payload: {}, optimisticValue: { name: 'confirmed' }, @@ -1362,8 +1433,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: 'complete-first-local-id', - serverIdHint: 42, + identity: { kind: 'generated', localId: 'complete-first-local-id', remoteIdHint: 42 }, operation: 'documents.create', payload: {}, optimisticValue: { name: 'recreated' }, @@ -1373,19 +1443,84 @@ describe('OfflineSyncService', () => { await service.flush(); expect(execute.mock.calls.map((call) => call[1])).toEqual([ - { localId: 'complete-first-local-id', serverId: 42 }, - { localId: 'complete-first-local-id', serverId: null }, + { kind: 'generated', localId: 'complete-first-local-id', remoteId: 42 }, + { kind: 'generated', localId: 'complete-first-local-id', remoteId: null }, + ]); + expect(rows).toEqual([ + expect.objectContaining({ + identity: { kind: 'generated', localId: 'complete-first-local-id', remoteId: 43 }, + values: { name: 'recreated' }, + }), + ]); + }); + + it('TEXT remoteIdのdelete ACK後recreateを同じlocalId・remoteId nullで送り新UUIDへ収束する', async () => { + options.replicaSchema = textReplicaSchema; + const localId = 'text-recreate-local'; + const oldRemoteId = '018f6f6e-74ad-7cc4-b94f-4af0b13c4401'; + const newRemoteId = '018f6f6e-74ad-7cc4-b94f-4af0b13c4402'; + rows.push({ + userId: 1, + scopeId: '10', + sourceKey: 'text_documents', + identity: { kind: 'generated', localId, remoteId: oldRemoteId }, + values: { id: oldRemoteId, title: 'old' }, + confirmedValues: { id: oldRemoteId, title: 'old' }, + serverRevision: 1, + fetchedAt: 1, + syncState: 'confirmed', + visibility: 'present', + }); + await service.enqueue( + { + scopeId: '10', + aggregateType: 'text_documents', + identity: { kind: 'generated', localId }, + operation: 'text_documents.delete', + payload: {}, + optimisticValue: { id: oldRemoteId, title: 'old' }, + replicaMutation: 'delete', + }, + { flush: false }, + ); + await service.enqueue( + { + scopeId: '10', + aggregateType: 'text_documents', + identity: { kind: 'generated', localId }, + operation: 'text_documents.create', + payload: { title: 'new' }, + optimisticValue: { id: '', title: 'new' }, + }, + { flush: false }, + ); + execute.mockResolvedValueOnce({ removeReplica: true, clearRemoteId: true, response: null }).mockResolvedValueOnce({ + remoteId: newRemoteId, + confirmedValues: { id: newRemoteId, title: 'new' }, + response: null, + }); + connected.set(true); + + await service.flush(); + + expect(execute.mock.calls.map((call) => call[1])).toEqual([ + { kind: 'generated', localId, remoteId: oldRemoteId }, + { kind: 'generated', localId, remoteId: null }, + ]); + expect(rows).toEqual([ + expect.objectContaining({ + identity: { kind: 'generated', localId, remoteId: newRemoteId }, + values: { id: newRemoteId, title: 'new' }, + }), ]); - expect(rows).toEqual([expect.objectContaining({ localId: 'complete-first-local-id', serverId: 43, values: { name: 'recreated' } })]); }); - it('clearServerIdはconfirmed delete以外では拒否する', async () => { + it('clearRemoteIdはconfirmed delete以外では拒否する', async () => { rows.push({ userId: 1, scopeId: '10', sourceKey: 'documents', - localId: 'invalid-clear', - serverId: 42, + identity: { kind: 'generated', localId: 'invalid-clear', remoteId: 42 }, values: { name: 'confirmed' }, confirmedValues: { name: 'confirmed' }, serverRevision: 4, @@ -1397,25 +1532,24 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: 'invalid-clear', + identity: { kind: 'generated', localId: 'invalid-clear' }, operation: 'documents.update', payload: {}, optimisticValue: { name: 'updated' }, }, { flush: false }, ); - execute.mockResolvedValueOnce({ clearServerId: true, response: null }); + execute.mockResolvedValueOnce({ clearRemoteId: true, response: null }); connected.set(true); - await expect(service.flush()).rejects.toThrow('Offline command can clear serverId only for a confirmed replica removal.'); + await expect(service.flush()).rejects.toThrow('Offline command can clear remoteId only for a confirmed replica removal.'); }); - it('clearServerIdとserverRevisionの同時返却は後続commandをrebaseせず拒否する', async () => { + it('clearRemoteIdとserverRevisionの同時返却は後続commandをrebaseせず拒否する', async () => { rows.push({ userId: 1, scopeId: '10', sourceKey: 'documents', - localId: 'invalid-clear-revision', - serverId: 42, + identity: { kind: 'generated', localId: 'invalid-clear-revision', remoteId: 42 }, values: { name: 'confirmed' }, confirmedValues: { name: 'confirmed' }, serverRevision: 4, @@ -1427,7 +1561,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: 'invalid-clear-revision', + identity: { kind: 'generated', localId: 'invalid-clear-revision' }, operation: 'documents.delete', payload: {}, optimisticValue: rows[0]!.values, @@ -1439,7 +1573,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: 'invalid-clear-revision', + identity: { kind: 'generated', localId: 'invalid-clear-revision' }, operation: 'documents.create', payload: {}, optimisticValue: { name: 'recreated' }, @@ -1450,13 +1584,13 @@ describe('OfflineSyncService', () => { rebase.mockClear(); execute.mockResolvedValueOnce({ removeReplica: true, - clearServerId: true, + clearRemoteId: true, serverRevision: 5, response: null, }); connected.set(true); - await expect(service.flush()).rejects.toThrow('Offline command cannot return serverRevision and clearServerId together.'); + await expect(service.flush()).rejects.toThrow('Offline command cannot return serverRevision and clearRemoteId together.'); expect(rebase).not.toHaveBeenCalled(); expect(commands[1]).toMatchObject({ baseRevision: 4 }); }); @@ -1466,8 +1600,7 @@ describe('OfflineSyncService', () => { userId: 1, scopeId: '10', sourceKey: 'documents', - localId: '019d-delete-discard', - serverId: 38142, + identity: { kind: 'generated', localId: '019d-delete-discard', remoteId: 38142 }, values: { name: 'confirmed' }, confirmedValues: { name: 'confirmed baseline' }, serverRevision: 4, @@ -1479,7 +1612,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '019d-delete-discard', + identity: { kind: 'generated', localId: '019d-delete-discard' }, operation: 'documents.delete', payload: { id: 38142 }, optimisticValue: { name: 'confirmed' }, @@ -1492,7 +1625,7 @@ describe('OfflineSyncService', () => { await service.discard(commandId, { flush: false }); expect(rows).toEqual([ expect.objectContaining({ - localId: '019d-delete-discard', + identity: { kind: 'generated', localId: '019d-delete-discard', remoteId: 38142 }, values: { name: 'confirmed baseline' }, confirmedValues: { name: 'confirmed baseline' }, visibility: 'present', @@ -1502,13 +1635,12 @@ describe('OfflineSyncService', () => { expect(commands).toEqual([]); }); - it('delete ACKはserverId/naturalKeyのidentity変更をhard failする', async () => { + it('delete ACKはremoteId/naturalKeyのidentity変更をhard failする', async () => { rows.push({ userId: 1, scopeId: '10', sourceKey: 'documents', - localId: 'delete-server-id', - serverId: 42, + identity: { kind: 'generated', localId: 'delete-server-id', remoteId: 42 }, values: { name: 'confirmed' }, confirmedValues: { name: 'confirmed' }, serverRevision: 4, @@ -1520,7 +1652,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: 'delete-server-id', + identity: { kind: 'generated', localId: 'delete-server-id' }, operation: 'documents.delete', payload: {}, optimisticValue: { name: 'confirmed' }, @@ -1528,9 +1660,9 @@ describe('OfflineSyncService', () => { }, { flush: false }, ); - execute.mockResolvedValueOnce({ removeReplica: true, serverId: 43, response: null }); + execute.mockResolvedValueOnce({ removeReplica: true, remoteId: 43, response: null }); connected.set(true); - await expect(service.flush()).rejects.toThrow('Offline replica serverId is immutable: current=42, incoming=43.'); + await expect(service.flush()).rejects.toThrow('Offline replica remote id is immutable: current=42, incoming=43.'); commands = []; rows = [ @@ -1538,8 +1670,7 @@ describe('OfflineSyncService', () => { userId: 1, scopeId: '10', sourceKey: 'natural_documents', - localId: 'delete-natural-key', - serverId: null, + identity: { kind: 'natural', naturalKey: { favFrom: 7, favTo: '42' } }, values: { favFrom: 7, favTo: '42', title: 'confirmed' }, confirmedValues: { favFrom: 7, favTo: '42', title: 'confirmed' }, serverRevision: 4, @@ -1553,7 +1684,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'natural_documents', - aggregateLocalId: 'delete-natural-key', + identity: { kind: 'natural', naturalKey: { favFrom: 7, favTo: '42' } }, operation: 'natural_documents.delete', payload: {}, optimisticValue: { favFrom: 7, favTo: '42', title: 'confirmed' }, @@ -1570,8 +1701,7 @@ describe('OfflineSyncService', () => { userId: 1, scopeId: '10', sourceKey: 'documents', - localId: 'delete-then-upsert', - serverId: 42, + identity: { kind: 'generated', localId: 'delete-then-upsert', remoteId: 42 }, values: { name: 'confirmed' }, confirmedValues: { name: 'old confirmed baseline' }, serverRevision: 4, @@ -1583,7 +1713,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: 'delete-then-upsert', + identity: { kind: 'generated', localId: 'delete-then-upsert' }, operation: 'documents.delete', payload: {}, optimisticValue: { name: 'confirmed' }, @@ -1595,7 +1725,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: 'delete-then-upsert', + identity: { kind: 'generated', localId: 'delete-then-upsert' }, operation: 'documents.update', payload: {}, optimisticValue: { name: 'later optimistic' }, @@ -1622,7 +1752,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: 'missing-tombstone-api', + identity: { kind: 'generated', localId: 'missing-tombstone-api' }, operation: 'documents.delete', payload: {}, optimisticValue: {}, @@ -1638,32 +1768,30 @@ describe('OfflineSyncService', () => { expect(rows).toEqual([]); }); - it.each([0, -1, 1.5])('enqueue時の不正serverId %sは永続化前にrejectする', async (serverId) => { + it.each([0, -1, 1.5])('enqueue時の不正remoteId %sは永続化前にrejectする', async (remoteId) => { await expect( service.enqueue( { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '019d-invalid', - serverId, + identity: { kind: 'generated', localId: '019d-invalid', remoteId }, operation: 'documents.update', payload: {}, optimisticValue: {}, }, { flush: false }, ), - ).rejects.toThrow(/invalid serverId/); + ).rejects.toThrow('Offline replica generated remote id must be a positive integer.'); expect(commands).toEqual([]); expect(rows).toEqual([]); }); - it('別localIdへ既存serverIdを割り当てようとするとrejectする', async () => { + it('別localIdへ既存remoteIdを割り当てようとするとrejectする', async () => { rows.push({ userId: 1, scopeId: '10', sourceKey: 'documents', - localId: '019d-existing', - serverId: 38142, + identity: { kind: 'generated', localId: '019d-existing', remoteId: 38142 }, values: {}, confirmedValues: {}, serverRevision: 1, @@ -1675,25 +1803,23 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '019d-new', - serverId: 38142, + identity: { kind: 'generated', localId: '019d-new', remoteId: 38142 }, operation: 'documents.update', payload: {}, optimisticValue: {}, }, { flush: false }, ), - ).rejects.toThrow('Offline replica serverId 38142 is already mapped to localId 019d-existing.'); + ).rejects.toThrow('Offline replica remote id 38142 is already mapped to another row.'); expect(commands).toEqual([]); }); - it('同一localIdへのserverId再指定は許容する', async () => { + it('同一localIdへのremoteId再指定は許容する', async () => { rows.push({ userId: 1, scopeId: '10', sourceKey: 'documents', - localId: '019d-same', - serverId: 38142, + identity: { kind: 'generated', localId: '019d-same', remoteId: 38142 }, values: { name: 'confirmed' }, confirmedValues: { name: 'confirmed' }, serverRevision: 1, @@ -1704,8 +1830,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '019d-same', - serverId: 38142, + identity: { kind: 'generated', localId: '019d-same', remoteId: 38142 }, operation: 'documents.update', payload: { name: 'draft' }, optimisticValue: { name: 'draft' }, @@ -1713,7 +1838,7 @@ describe('OfflineSyncService', () => { }, { flush: false }, ); - expect(rows[0]).toMatchObject({ localId: '019d-same', serverId: 38142, syncState: 'pending' }); + expect(rows[0]).toMatchObject({ identity: { kind: 'generated', localId: '019d-same', remoteId: 38142 }, syncState: 'pending' }); expect(commands).toHaveLength(1); }); @@ -1722,8 +1847,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '019d-adopted', - serverId: 38142, + identity: { kind: 'generated', localId: '019d-adopted', remoteId: 38142 }, operation: 'documents.update', payload: { name: 'adopted' }, optimisticValue: { name: 'adopted' }, @@ -1740,8 +1864,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '019d-adopted', - serverId: 38142, + identity: { kind: 'generated', localId: '019d-adopted', remoteId: 38142 }, operation: 'documents.update', payload: { name: 'adopted' }, optimisticValue: { name: 'adopted' }, @@ -1759,7 +1882,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'documents', - aggregateLocalId: '1', + identity: { kind: 'generated', localId: '1' }, operation: 'documents.upsert', payload: {}, optimisticValue: {}, @@ -1779,7 +1902,7 @@ describe('OfflineSyncService', () => { sourceKey: 'test_items', scope: 'user', fields: { - id: serverId(), + id: generatedId('integer'), title: text(), }, }), @@ -1788,7 +1911,7 @@ describe('OfflineSyncService', () => { sourceKey: 'test_group_items', scope: 'partition', fields: { - id: serverId(), + id: generatedId('integer'), name: text(), }, }), @@ -1808,9 +1931,12 @@ describe('OfflineSyncService', () => { return left.createdAt - right.createdAt || (left.commandId < right.commandId ? -1 : left.commandId > right.commandId ? 1 : 0); } - function findReplicaRow(scope: OfflineScope, sourceKey: string, localId: string): OfflineReplicaRow | undefined { + function findReplicaRow(scope: OfflineScope, sourceKey: string, identity: OfflineReplicaAddress): OfflineReplicaRow | undefined { return rows.find((item) => { - if (item.userId !== scope.userId || item.sourceKey !== sourceKey || item.localId !== localId) return false; + if (item.userId !== scope.userId || item.sourceKey !== sourceKey) return false; + if (identity.kind === 'natural' || item.identity.kind === 'natural' || item.identity.localId !== identity.localId) { + return false; + } return userScopedSourceKeys.has(sourceKey) ? true : item.scopeId === scope.scopeId; }); } @@ -1850,21 +1976,33 @@ describe('OfflineSyncService', () => { removeCommand: vi.fn(async (commandId: string) => { commands = commands.filter((item) => item.commandId !== commandId); }), - getReplicaRow: vi.fn(async (scope: OfflineScope, sourceKey: string, localId: string) => { - const row = findReplicaRow(scope, sourceKey, localId); + getReplicaRow: vi.fn(async (scope: OfflineScope, sourceKey: string, identity: OfflineReplicaAddress) => { + const row = findReplicaRow(scope, sourceKey, identity); return row ? projectReplicaRow(row, scope) : null; }), - getReplicaRowByServerId: vi.fn(async (scope: OfflineScope, sourceKey: string, serverId: number) => { + getReplicaRowByRemoteId: vi.fn(async (scope: OfflineScope, sourceKey: string, remoteId: number) => { const row = rows.find((item) => { - if (item.userId !== scope.userId || item.sourceKey !== sourceKey || item.serverId !== serverId) return false; + if ( + item.userId !== scope.userId || + item.sourceKey !== sourceKey || + item.identity.kind !== 'generated' || + item.identity.remoteId !== remoteId + ) + return false; return userScopedSourceKeys.has(sourceKey) ? true : item.scopeId === scope.scopeId; }); return row ? projectReplicaRow(row, scope) : null; }), getReplicaRowByRemoteIdentity: vi.fn(async (scope: OfflineScope, sourceKey: string, identity) => { - if (identity.serverId === undefined) throw new Error(`Natural identity is unsupported by this test repository.`); + if (identity.remoteId === undefined) throw new Error(`Natural identity is unsupported by this test repository.`); const row = rows.find((item) => { - if (item.userId !== scope.userId || item.sourceKey !== sourceKey || item.serverId !== identity.serverId) return false; + if ( + item.userId !== scope.userId || + item.sourceKey !== sourceKey || + item.identity.kind !== 'generated' || + item.identity.remoteId !== identity.remoteId + ) + return false; return userScopedSourceKeys.has(sourceKey) ? true : item.scopeId === scope.scopeId; }); return row ? projectReplicaRow(row, scope) : null; @@ -1872,12 +2010,12 @@ describe('OfflineSyncService', () => { getReplicaCursor: vi.fn(async () => null), transactReplica: vi.fn(async (transaction) => { for (const row of transaction.putRows ?? []) { - const existing = findReplicaRow(row, row.sourceKey, row.localId); + const existing = findReplicaRow(row, row.sourceKey, row.identity); rows = rows.filter( (item) => item.userId !== row.userId || item.sourceKey !== row.sourceKey || - item.localId !== row.localId || + canonicalOfflineReplicaIdentity(item.identity) !== canonicalOfflineReplicaIdentity(row.identity) || (!userScopedSourceKeys.has(row.sourceKey) && item.scopeId !== row.scopeId), ); rows.push(structuredClone(existing ? { ...existing, ...row } : row)); @@ -1887,7 +2025,7 @@ describe('OfflineSyncService', () => { (item) => item.userId !== key.userId || item.sourceKey !== key.sourceKey || - item.localId !== key.localId || + canonicalOfflineReplicaIdentity(item.identity) !== canonicalOfflineReplicaIdentity(key.identity) || (!userScopedSourceKeys.has(key.sourceKey) && item.scopeId !== key.scopeId), ); } @@ -1928,8 +2066,7 @@ describe('OfflineSyncService', () => { userId: 1, scopeId: '10', sourceKey: 'test_items', - localId: '019d-user-item', - serverId: 42, + identity: { kind: 'generated', localId: '019d-user-item', remoteId: 42 }, values: { id: 42, title: 'Baseline' }, confirmedValues: { id: 42, title: 'Baseline' }, serverRevision: 1, @@ -1948,7 +2085,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'test_items', - aggregateLocalId: '019d-user-item', + identity: { kind: 'generated', localId: '019d-user-item' }, operation: 'test_items.update', payload: { title: 'G10 edit' }, optimisticValue: { id: 42, title: 'G10 edit' }, @@ -1960,7 +2097,7 @@ describe('OfflineSyncService', () => { { scopeId: '11', aggregateType: 'test_items', - aggregateLocalId: '019d-user-item', + identity: { kind: 'generated', localId: '019d-user-item' }, operation: 'test_items.update', payload: { title: 'G11 edit' }, optimisticValue: { id: 42, title: 'G11 edit' }, @@ -1978,7 +2115,12 @@ describe('OfflineSyncService', () => { baseRevision: 2, optimisticValue: { id: 42, title: 'G11 edit' }, }); - expect(findReplicaRow({ userId: 1, scopeId: '10' }, 'test_items', '019d-user-item')).toMatchObject({ + expect( + findReplicaRow({ userId: 1, scopeId: '10' }, 'test_items', { + kind: 'generated', + localId: '019d-user-item', + }), + ).toMatchObject({ values: { title: 'G11 edit' }, confirmedValues: { title: 'G10 edit' }, serverRevision: 2, @@ -1998,7 +2140,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'test_items', - aggregateLocalId: '019d-user-item', + identity: { kind: 'generated', localId: '019d-user-item' }, operation: 'test_items.update', payload: { title: 'G10 edit' }, optimisticValue: { id: 42, title: 'G10 edit' }, @@ -2010,7 +2152,7 @@ describe('OfflineSyncService', () => { { scopeId: '11', aggregateType: 'test_items', - aggregateLocalId: '019d-user-item', + identity: { kind: 'generated', localId: '019d-user-item' }, operation: 'test_items.update', payload: { title: 'G11 edit' }, optimisticValue: { id: 42, title: 'G11 edit' }, @@ -2021,7 +2163,12 @@ describe('OfflineSyncService', () => { await service.discard(firstId, { flush: false }); expect(commands).toHaveLength(1); expect(commands[0]).toMatchObject({ scopeId: '11', optimisticValue: { id: 42, title: 'G11 edit' } }); - expect(findReplicaRow({ userId: 1, scopeId: '11' }, 'test_items', '019d-user-item')).toMatchObject({ + expect( + findReplicaRow({ userId: 1, scopeId: '11' }, 'test_items', { + kind: 'generated', + localId: '019d-user-item', + }), + ).toMatchObject({ values: { title: 'G11 edit' }, confirmedValues: { title: 'Baseline' }, syncState: 'pending', @@ -2036,8 +2183,7 @@ describe('OfflineSyncService', () => { userId: 1, scopeId: '11', sourceKey: 'test_group_items', - localId: '019d-group-same', - serverId: 55, + identity: { kind: 'generated', localId: '019d-group-same', remoteId: 55 }, values: { id: 55, name: 'G11 baseline' }, confirmedValues: { id: 55, name: 'G11 baseline' }, serverRevision: 1, @@ -2048,8 +2194,7 @@ describe('OfflineSyncService', () => { userId: 1, scopeId: '10', sourceKey: 'test_group_items', - localId: '019d-group-same', - serverId: 56, + identity: { kind: 'generated', localId: '019d-group-same', remoteId: 56 }, values: { id: 56, name: 'G10 baseline' }, confirmedValues: { id: 56, name: 'G10 baseline' }, serverRevision: 1, @@ -2060,7 +2205,7 @@ describe('OfflineSyncService', () => { { scopeId: '10', aggregateType: 'test_group_items', - aggregateLocalId: '019d-group-same', + identity: { kind: 'generated', localId: '019d-group-same' }, operation: 'test_group_items.update', payload: { name: 'G10 name' }, optimisticValue: { id: 56, name: 'G10 name' }, @@ -2072,7 +2217,7 @@ describe('OfflineSyncService', () => { { scopeId: '11', aggregateType: 'test_group_items', - aggregateLocalId: '019d-group-same', + identity: { kind: 'generated', localId: '019d-group-same' }, operation: 'test_group_items.update', payload: { name: 'G11 name' }, optimisticValue: { id: 55, name: 'G11 name' }, diff --git a/projects/kit/offline/src/lib/offline-sync.service.ts b/projects/kit/offline/src/lib/offline-sync.service.ts index 45c4706..9a90fbb 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.ts @@ -2,7 +2,10 @@ import { computed, effect, ErrorHandler, inject, Injectable, signal } from '@ang import { OFFLINE_COMMAND_EXECUTOR, OFFLINE_SYNC_CONTEXT, + offlineCommandLookupIdentity, + offlineCommandTargetFromReplicaRow, type OfflineCommandResult, + type EnqueueOfflineCommandIdentity, type OfflineSyncSession, } from './offline-command-executor'; import { OFFLINE_COMMAND_HOOKS } from './offline-command-hooks'; @@ -20,9 +23,20 @@ import type { } from './offline-repository'; import { OFFLINE_REPOSITORY } from './offline-repository'; import { + canonicalOfflinePrincipalId, + canonicalOfflineCommandIdentity, + commandIdentityFromReplicaIdentity, + commandIdentityMatchesReplicaRow, + offlineGeneratedReplicaIdentity, + offlineNaturalReplicaIdentity, + type OfflineCommandIdentity, + type OfflinePrincipalId, +} from './offline-identity'; +import { + assertOfflineReplicaGeneratedRemoteId, canonicalOfflineRemoteIdentity, offlineNaturalKeyFromValues, - type OfflineReplicaRemoteIdentity, + type OfflineGeneratedRemoteId, type OfflineReplicaEntitySchema, } from './offline-replica-schema'; @@ -33,20 +47,7 @@ export type OfflineSyncState = 'idle' | 'pending' | 'syncing' | 'attention'; export interface EnqueueOfflineCommand { scopeId: string; aggregateType: string; - /** Stable UUID generated by the client before the entity is first written. */ - aggregateLocalId: string; - /** - * Backward-compatible explicit server-id adoption. When no replica row - * exists, this initializes its server mapping; use `serverIdHint` for a - * stale product-side read that must never create such a mapping. - */ - serverId?: number | null; - /** - * Optional consistency check for an already replicated row. It is ignored - * when the local row does not exist, so it cannot resurrect an - * AUTO_INCREMENT id after delete/recreate. - */ - serverIdHint?: number | null; + identity: EnqueueOfflineCommandIdentity; operation: string; payload: T; /** Full local entity value committed to the replica before the command is exposed to the UI. */ @@ -101,7 +102,7 @@ export class OfflineSyncService { readonly #errorHandler = inject(ErrorHandler); readonly #commands = signal([]); readonly #knownScopes = new Map(); - #activeUserId: number | null = null; + #activeUserId: OfflinePrincipalId | null = null; #flushPromise: Promise | null = null; readonly #flushTransitions = new Set>(); #generation = 0; @@ -230,19 +231,25 @@ export class OfflineSyncService { await this.initialize(); const session = await this.#getLocalSession(); if (!session) throw new Error('Cannot enqueue an offline command without an authenticated user'); + this.#assertSessionPrincipalBoundary(session); const userId = session.userId; this.#setActiveUser(userId); const scope = { userId, scopeId: request.scopeId }; + if (!session.scopes.some((candidate) => candidate.userId === userId && candidate.scopeId === request.scopeId)) { + throw new Error(`Offline sync session does not include scope "${request.scopeId}".`); + } this.noteScope(scope); - const aggregateLocalId = request.aggregateLocalId; - const normalized = await this.#normalizeEnqueueRequest(scope, request); + const commandIdentity = offlineCommandLookupIdentity(request.identity); + const normalized = await this.#normalizeEnqueueRequest(scope, request, commandIdentity); const optimisticValue = request.optimisticValue; const commandId = crypto.randomUUID(); + const sourceKey = this.#hooks.entityType(request); const command: OfflineCommand = { ...scope, commandId, aggregateType: request.aggregateType, - aggregateLocalId, + sourceKey, + identity: commandIdentity, operation: request.operation, payload: normalized.payload, optimisticValue, @@ -256,49 +263,77 @@ export class OfflineSyncService { lastErrorCode: null, }; await this.#assertOutboxCapacity(userId, command); - const entityType = this.#entityType(command); + const entityType = command.sourceKey; const schema = this.#entitySchema(entityType); + if (schema.identity.kind === 'localOnly') { + throw new Error(`Offline replica source "${entityType}" is local-only and cannot be added to the Outbox.`); + } + if (schema.identity.kind === 'generated' && request.identity.kind !== 'generated') { + throw new Error(`Offline replica source "${entityType}" requires generated identity.`); + } + if (schema.identity.kind === 'naturalKey' && request.identity.kind !== 'natural') { + throw new Error(`Offline replica source "${entityType}" requires natural identity.`); + } if (request.replicaMutation === 'delete' && !this.#repository.getReplicaRowIncludingPendingDelete) { throw new Error('Offline repository does not support durable replica delete tombstones.'); } - const existing = await this.#getReplicaRowForSync(scope, entityType, aggregateLocalId); - const initialServerId = this.#initialServerId(existing?.serverId ?? null, request.serverId); - this.#assertServerIdHint(existing?.serverId ?? null, request.serverIdHint); + const existing = await this.#getReplicaRowForSync(scope, entityType, commandIdentity); + const generatedIdentity = request.identity.kind === 'generated' ? request.identity : null; + const initialRemoteId = this.#initialRemoteId( + schema, + existing?.identity.kind === 'generated' ? existing.identity.remoteId : null, + generatedIdentity?.remoteId, + ); + this.#assertRemoteIdHint( + schema, + existing?.identity.kind === 'generated' ? existing.identity.remoteId : null, + generatedIdentity?.remoteIdHint, + ); const naturalKey = offlineNaturalKeyFromValues(schema, optimisticValue); - const remoteIdentity: OfflineReplicaRemoteIdentity | null = - schema.identity.kind === 'serverId' - ? initialServerId === null + if ( + schema.identity.kind === 'naturalKey' && + canonicalOfflineRemoteIdentity(schema, { naturalKey: request.identity.kind === 'natural' ? request.identity.naturalKey : {} }) !== + canonicalOfflineRemoteIdentity(schema, { naturalKey: naturalKey! }) + ) { + throw new Error(`Offline command naturalKey must match optimistic values for "${entityType}".`); + } + const remoteIdentity = + schema.identity.kind === 'generated' + ? initialRemoteId === null ? null - : { serverId: initialServerId } + : { remoteId: initialRemoteId } : schema.identity.kind === 'naturalKey' ? { naturalKey: naturalKey! } : null; - if (schema.identity.kind === 'naturalKey' && (request.serverId != null || request.serverIdHint != null)) { - throw new Error(`Offline replica source "${entityType}" does not define a serverId field.`); - } if (schema.identity.kind === 'naturalKey') { const canonicalValuesKey = canonicalOfflineRemoteIdentity(schema, remoteIdentity!); if ( existing && - canonicalOfflineRemoteIdentity(schema, { naturalKey: offlineNaturalKeyFromValues(schema, existing.values)! }) !== canonicalValuesKey + canonicalOfflineRemoteIdentity(schema, { + naturalKey: + existing.identity.kind === 'natural' ? existing.identity.naturalKey : offlineNaturalKeyFromValues(schema, existing.values)!, + }) !== canonicalValuesKey ) { throw new Error(`Offline replica naturalKey is immutable and must match optimistic values for "${entityType}".`); } } if (remoteIdentity !== null) { const mapped = await this.#repository.getReplicaRowByRemoteIdentity(scope, entityType, remoteIdentity); - if (mapped !== null && mapped.localId !== aggregateLocalId) { - if (remoteIdentity.serverId !== undefined) { - throw new Error(`Offline replica serverId ${String(remoteIdentity.serverId)} is already mapped to localId ${mapped.localId}.`); + if (mapped !== null && !commandIdentityMatchesReplicaRow(schema, mapped, commandIdentity)) { + if ('remoteId' in remoteIdentity) { + throw new Error(`Offline replica remote id ${String(remoteIdentity.remoteId)} is already mapped to another row.`); } - throw new Error(`Offline replica remote identity is already mapped to localId ${mapped.localId}.`); + throw new Error(`Offline replica remote identity is already mapped to another row.`); } } + const rowIdentity: import('./offline-identity').OfflineReplicaIdentity = + schema.identity.kind === 'naturalKey' + ? offlineNaturalReplicaIdentity(schema, optimisticValue) + : offlineGeneratedReplicaIdentity(generatedIdentity!.localId, initialRemoteId); const optimisticRow: OfflineReplicaRow = { ...scope, sourceKey: entityType, - localId: aggregateLocalId, - serverId: initialServerId, + identity: rowIdentity, values: optimisticValue, confirmedValues: existing?.confirmedValues ?? existing?.values ?? null, serverRevision: existing?.serverRevision ?? normalized.baseRevision, @@ -315,7 +350,7 @@ export class OfflineSyncService { return commandId; } - async #assertOutboxCapacity(userId: number, command: OfflineCommand): Promise { + async #assertOutboxCapacity(userId: OfflinePrincipalId, command: OfflineCommand): Promise { const commands = this.#repository.getCommandsForUser ? await this.#repository.getCommandsForUser(userId) : ( @@ -437,16 +472,11 @@ export class OfflineSyncService { await this.#refreshState(generation); if (!this.#isCurrent(generation)) return; const row = await this.#rowForCommand(sending); - if (!row) throw new Error(`Offline replica row not found: ${sending.aggregateType}/${sending.aggregateLocalId}`); + if (!row) + throw new Error(`Offline replica row not found: ${sending.aggregateType}/${canonicalOfflineCommandIdentity(sending.identity)}`); let result: OfflineCommandResult; try { - const schema = this.#entitySchema(row.sourceKey); - const naturalKey = offlineNaturalKeyFromValues(schema, row.values); - result = await this.#executor.execute(sending, { - localId: row.localId, - serverId: row.serverId, - ...(naturalKey !== null ? { naturalKey } : {}), - }); + result = await this.#executor.execute(sending, offlineCommandTargetFromReplicaRow(row)); } catch (error) { if (!this.#isCurrent(generation)) return; if (!this.#isClassifiableTransportError(error)) throw error; @@ -466,14 +496,15 @@ export class OfflineSyncService { async #normalizeEnqueueRequest( scope: OfflineScope, request: EnqueueOfflineCommand, + commandIdentity: OfflineCommandIdentity, ): Promise<{ payload: T; baseRevision: string | number | null }> { let baseRevision = request.baseRevision ?? null; let payload = request.payload; - const aggregateLocalId = request.aggregateLocalId; - const row = await this.#getReplicaRowForSync(scope, this.#entityType(request), aggregateLocalId); + const sourceKey = this.#hooks.entityType(request); + const row = await this.#getReplicaRowForSync(scope, sourceKey, commandIdentity); if (row?.serverRevision != null && row.serverRevision !== baseRevision) { const rebased = this.#executor.withServerRevision( - { ...scope, ...request, aggregateLocalId, payload, baseRevision } as OfflineCommand, + { ...scope, ...request, sourceKey, identity: commandIdentity, payload, baseRevision } as OfflineCommand, row.serverRevision, ); baseRevision = row.serverRevision; @@ -497,11 +528,11 @@ export class OfflineSyncService { result: OfflineCommandResult, generation: number, ): Promise { - if (result.clearServerId === true && result.serverId !== undefined) { - throw new Error('Offline command cannot return serverId and clearServerId together.'); + if (result.clearRemoteId === true && result.remoteId !== undefined) { + throw new Error('Offline command cannot return remoteId and clearRemoteId together.'); } - if (result.clearServerId === true && result.serverRevision !== undefined) { - throw new Error('Offline command cannot return serverRevision and clearServerId together.'); + if (result.clearRemoteId === true && result.serverRevision !== undefined) { + throw new Error('Offline command cannot return serverRevision and clearRemoteId together.'); } // An enqueue may have completed while transport was in flight. Re-read the // aggregate immediately before the atomic acknowledgement transaction so a @@ -514,10 +545,10 @@ export class OfflineSyncService { const revision = result.serverRevision; const following = latestCommands.slice(latestIndex + 1); const rebased = - result.clearServerId === true + result.clearRemoteId === true ? following.map((item) => { if (!this.#executor.withoutServerRevision) { - throw new Error('Offline command executor must implement withoutServerRevision to recreate a deleted serverId row.'); + throw new Error('Offline command executor must implement withoutServerRevision to recreate a deleted remoteId row.'); } return this.#executor.withoutServerRevision(item); }) @@ -532,23 +563,25 @@ export class OfflineSyncService { } this.#assertServerRevision(result.serverRevision); const removesReplica = result.removeReplica === true || command.replicaMutation === 'delete'; - if (result.clearServerId === true && !removesReplica) { - throw new Error('Offline command can clear serverId only for a confirmed replica removal.'); + if (result.clearRemoteId === true && !removesReplica) { + throw new Error('Offline command can clear remoteId only for a confirmed replica removal.'); } const confirmedValues = removesReplica ? null : (result.confirmedValues ?? command.optimisticValue); const schema = this.#entitySchema(current.sourceKey); this.#assertCommandResultIdentity(schema, current, result); - const serverId = result.clearServerId === true ? null : this.#resolvedServerId(current.serverId, result.serverId); + const resolvedRemoteId = this.#resolvedRemoteId(current, result); const row = { ...current, - // The row may have received a local-only patch (for example, a UI - // projection was integrated) while this command was in flight. Keep the - // current durable optimistic state instead of restoring the stale - // command snapshot when later commands remain queued. values: rebased.length > 0 ? current.values : confirmedValues, confirmedValues, - serverId, - serverRevision: result.clearServerId === true ? null : (revision ?? current.serverRevision), + identity: + current.identity.kind === 'generated' + ? { + ...current.identity, + remoteId: result.clearRemoteId === true ? null : resolvedRemoteId, + } + : current.identity, + serverRevision: result.clearRemoteId === true ? null : (revision ?? current.serverRevision), fetchedAt: Date.now(), syncState: rebased.length > 0 ? ('pending' as const) : ('confirmed' as const), visibility: rebased.at(-1)?.replicaMutation === 'delete' ? ('pending_delete' as const) : ('present' as const), @@ -556,15 +589,18 @@ export class OfflineSyncService { if (!this.#isCurrent(generation)) return; await this.#repository.transactReplica({ putRows: removesReplica && rebased.length === 0 ? undefined : [row], - releaseServerIds: - result.clearServerId === true && current.serverId !== null && !(removesReplica && rebased.length === 0) + releaseRemoteIds: + result.clearRemoteId === true && + current.identity.kind === 'generated' && + current.identity.remoteId !== null && + !(removesReplica && rebased.length === 0) ? [ { userId: current.userId, scopeId: current.scopeId, sourceKey: current.sourceKey, - localId: current.localId, - serverId: current.serverId, + identity: current.identity, + remoteId: current.identity.remoteId, }, ] : undefined, @@ -578,13 +614,13 @@ export class OfflineSyncService { async #rowForCommand(command: OfflineCommand): Promise { const scope = { userId: command.userId, scopeId: command.scopeId }; const entityType = this.#entityType(command); - return this.#getReplicaRowForSync(scope, entityType, command.aggregateLocalId); + return this.#getReplicaRowForSync(scope, entityType, command.identity); } - #getReplicaRowForSync(scope: OfflineScope, sourceKey: string, localId: string): Promise { + #getReplicaRowForSync(scope: OfflineScope, sourceKey: string, identity: OfflineCommandIdentity): Promise { return ( - this.#repository.getReplicaRowIncludingPendingDelete?.(scope, sourceKey, localId) ?? - this.#repository.getReplicaRow(scope, sourceKey, localId) + this.#repository.getReplicaRowIncludingPendingDelete?.(scope, sourceKey, identity) ?? + this.#repository.getReplicaRow(scope, sourceKey, identity) ); } @@ -593,8 +629,8 @@ export class OfflineSyncService { return 'pending'; } - #entityType(command: Pick): string { - return this.#hooks.entityType(command); + #entityType(command: Pick): string { + return command.sourceKey; } #entitySchema(sourceKey: string): OfflineReplicaEntitySchema> { @@ -637,7 +673,7 @@ export class OfflineSyncService { const schema = this.#options.replicaSchema.entities.find((entity) => entity.sourceKey === sourceKey); if (!schema) throw new Error(`Unknown offline replica source key "${sourceKey}".`); const partition = schema.scope === 'user' ? 'user' : `partition:${command.scopeId}`; - return `${command.userId}:${partition}:${sourceKey}:${command.aggregateLocalId}`; + return `${canonicalOfflinePrincipalId(command.userId)}:${partition}:${sourceKey}:${canonicalOfflineCommandIdentity(command.identity)}`; } #failedCommand(command: OfflineCommand, error: unknown): OfflineCommand { @@ -669,28 +705,42 @@ export class OfflineSyncService { } } - #resolvedServerId(current: number | null, incoming: number | undefined): number | null { - if (incoming === undefined) return current; - if (!Number.isSafeInteger(incoming) || incoming <= 0) { - throw new Error(`Offline command returned invalid serverId ${String(incoming)}.`); - } - if (current !== null && current !== incoming) { - throw new Error(`Offline replica serverId is immutable: current=${current}, incoming=${incoming}.`); + #resolvedRemoteId(current: OfflineReplicaRow, result: OfflineCommandResult): OfflineGeneratedRemoteId | null { + if (current.identity.kind !== 'generated') return null; + const incoming = result.remoteId; + if (incoming === undefined) return current.identity.remoteId; + const schema = this.#entitySchema(current.sourceKey); + assertOfflineReplicaGeneratedRemoteId(schema, incoming); + if (current.identity.remoteId !== null && current.identity.remoteId !== incoming) { + throw new Error( + `Offline replica remote id is immutable: current=${String(current.identity.remoteId)}, incoming=${String(incoming)}.`, + ); } return incoming; } - #initialServerId(current: number | null, incoming: number | null | undefined): number | null { - return incoming === null || incoming === undefined ? current : this.#resolvedServerId(current, incoming); + #initialRemoteId( + schema: OfflineReplicaEntitySchema>, + current: OfflineGeneratedRemoteId | null, + incoming: OfflineGeneratedRemoteId | null | undefined, + ): OfflineGeneratedRemoteId | null { + if (incoming === null || incoming === undefined) return current; + assertOfflineReplicaGeneratedRemoteId(schema, incoming); + if (current !== null && current !== incoming) { + throw new Error(`Offline replica remote id is immutable: current=${String(current)}, incoming=${String(incoming)}.`); + } + return incoming; } - #assertServerIdHint(current: number | null, hint: number | null | undefined): void { + #assertRemoteIdHint( + schema: OfflineReplicaEntitySchema>, + current: OfflineGeneratedRemoteId | null, + hint: OfflineGeneratedRemoteId | null | undefined, + ): void { if (hint === null || hint === undefined) return; - if (!Number.isSafeInteger(hint) || hint <= 0) { - throw new Error(`Offline command returned invalid serverId hint ${String(hint)}.`); - } + assertOfflineReplicaGeneratedRemoteId(schema, hint); if (current !== null && current !== hint) { - throw new Error(`Offline replica serverId hint does not match current=${current}: incoming=${hint}.`); + throw new Error(`Offline replica remoteId hint does not match current=${String(current)}: incoming=${String(hint)}.`); } } @@ -699,18 +749,19 @@ export class OfflineSyncService { current: OfflineReplicaRow, result: OfflineCommandResult, ): void { - if (result.clearServerId === true && schema.identity.kind !== 'serverId') { - throw new Error(`Offline command cannot clear serverId for source "${schema.sourceKey}".`); + if (result.clearRemoteId === true && schema.identity.kind !== 'generated') { + throw new Error(`Offline command cannot clear remoteId for source "${schema.sourceKey}".`); } - if (schema.identity.kind === 'naturalKey' && result.serverId !== undefined) { - throw new Error(`Offline command returned serverId for naturalKey source "${schema.sourceKey}".`); + if (schema.identity.kind === 'naturalKey' && result.remoteId !== undefined) { + throw new Error(`Offline command returned generated remote id for naturalKey source "${schema.sourceKey}".`); } - if (schema.identity.kind !== 'serverId' && result.serverId !== undefined) { - throw new Error(`Offline command returned serverId for source "${schema.sourceKey}" without serverId identity.`); + if (schema.identity.kind !== 'generated' && result.remoteId !== undefined) { + throw new Error(`Offline command returned generated remote id for source "${schema.sourceKey}" without generated identity.`); } if (schema.identity.kind === 'naturalKey') { const confirmedValues = result.confirmedValues ?? current.values; - const currentKey = offlineNaturalKeyFromValues(schema, current.values)!; + const currentKey = + current.identity.kind === 'natural' ? current.identity.naturalKey : offlineNaturalKeyFromValues(schema, current.values)!; const confirmedKey = offlineNaturalKeyFromValues(schema, confirmedValues)!; if ( canonicalOfflineRemoteIdentity(schema, { naturalKey: currentKey }) !== @@ -727,6 +778,7 @@ export class OfflineSyncService { if (!session) { return false; } + this.#assertSessionPrincipalBoundary(session); this.#setActiveUser(session.userId); this.#knownScopes.clear(); for (const scope of session.scopes) this.#knownScopes.set(this.#scopeKey(scope), scope); @@ -741,6 +793,7 @@ export class OfflineSyncService { this.#knownScopes.clear(); return true; } + this.#assertSessionPrincipalBoundary(session); this.#setActiveUser(session.userId); this.#knownScopes.clear(); for (const scope of session.scopes) this.#knownScopes.set(this.#scopeKey(scope), scope); @@ -751,13 +804,22 @@ export class OfflineSyncService { return this.#context.getLocalSession?.() ?? this.#context.getSession(); } - #setActiveUser(userId: number): void { + #setActiveUser(userId: OfflinePrincipalId): void { if (this.#activeUserId === userId) return; this.#knownScopes.clear(); this.#activeUserId = userId; this.#lastCommandCreatedAt = 0; } + #assertSessionPrincipalBoundary(session: OfflineSyncSession): void { + canonicalOfflinePrincipalId(session.userId); + for (const scope of session.scopes) { + if (scope.userId !== session.userId) { + throw new Error('Offline sync session scope belongs to a different principal.'); + } + } + } + async #readKnownCommands(): Promise { const commands = (await Promise.all([...this.#knownScopes.values()].map((scope) => this.#repository.getCommands(scope)))) .flat() @@ -766,7 +828,7 @@ export class OfflineSyncService { return commands; } - async #nextCommandCreatedAt(userId: number): Promise { + async #nextCommandCreatedAt(userId: OfflinePrincipalId): Promise { const commands = this.#repository.getCommandsForUser ? await this.#repository.getCommandsForUser(userId) : await this.#readKnownCommands(); @@ -863,7 +925,7 @@ export class OfflineSyncService { } #scopeKey(scope: OfflineScope): string { - return `${scope.userId}:${scope.scopeId}`; + return `${canonicalOfflinePrincipalId(scope.userId)}:${scope.scopeId}`; } } diff --git a/projects/kit/offline/src/lib/offline-test-helpers.ts b/projects/kit/offline/src/lib/offline-test-helpers.ts new file mode 100644 index 0000000..c48ac1a --- /dev/null +++ b/projects/kit/offline/src/lib/offline-test-helpers.ts @@ -0,0 +1,22 @@ +import type { OfflineCommandIdentity, OfflineReplicaIdentity } from './offline-identity'; +import type { OfflineGeneratedRemoteId, OfflineNaturalKey } from './offline-replica-schema'; + +/** Generated replica/command identity for tests. */ +export function generatedReplicaIdentity(localId: string, remoteId: OfflineGeneratedRemoteId | null = null): OfflineReplicaIdentity { + return { kind: 'generated', localId, remoteId }; +} + +/** Natural replica/command identity for tests. */ +export function naturalReplicaIdentity(naturalKey: OfflineNaturalKey): OfflineReplicaIdentity { + return { kind: 'natural', naturalKey }; +} + +/** Generated outbox identity for tests. */ +export function generatedCommandIdentity(localId: string): OfflineCommandIdentity { + return { kind: 'generated', localId }; +} + +/** Natural outbox identity for tests. */ +export function naturalCommandIdentity(naturalKey: OfflineNaturalKey): OfflineCommandIdentity { + return { kind: 'natural', naturalKey }; +} diff --git a/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts b/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts index 8e89304..511d9e8 100644 --- a/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts +++ b/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts @@ -6,12 +6,15 @@ import { defineOfflineReplicaSchema, defineReplicaEntity, integer, + localOnly, naturalKey, - serverId, + generatedId, sha256OfflineReplicaSchema, text, type OfflineReplicaSchemaBundle, } from './offline-replica-schema'; +import { canonicalOfflinePrincipalId, type OfflineCommand, type OfflineReplicaRow } from './offline-repository'; +import { generatedCommandIdentity, generatedReplicaIdentity, naturalReplicaIdentity } from './offline-test-helpers'; import { COMMUNITY_SQLITE, type CommunitySqliteConnection, @@ -24,13 +27,14 @@ import { type TestItemSelect = { id: number; title: string }; type TestItemWithSubtitleSelect = { id: number; title: string; subtitle: string }; type LocalProjectionSelect = { feedKey: string }; +type TextIdSelect = { id: string; title: string }; const testItemEntity = defineReplicaEntity()({ table: 'test_items', sourceKey: 'test_items', scope: 'user', fields: { - id: serverId(), + id: generatedId('integer'), title: text(), }, }); @@ -40,7 +44,7 @@ const testItemWithSubtitleEntity = defineReplicaEntity()( sourceKey: 'test_group_items', scope: 'partition', fields: { - id: serverId(), + id: generatedId('integer'), name: text(), }, }); @@ -60,6 +64,7 @@ const localProjectionEntity = defineReplicaEntity()({ table: 'local_projections', sourceKey: 'local_projections', scope: 'user', + identity: localOnly(), fields: { feedKey: text(), }, @@ -73,6 +78,19 @@ const naturalFavoriteEntity = defineReplicaEntity<{ favFrom: number; favTo: stri fields: { favFrom: integer(), favTo: text(), label: text() }, }); +const textIdEntity = defineReplicaEntity()({ + table: 'text_id_items', + sourceKey: 'text_id_items', + scope: 'user', + fields: { id: generatedId('text'), title: text() }, +}); + +const textIdSchema = defineOfflineReplicaSchema({ + version: 1, + entities: [textIdEntity], + migrations: [], +}); + const naturalFavoriteSchema = defineOfflineReplicaSchema({ version: 1, entities: [naturalFavoriteEntity], @@ -238,16 +256,15 @@ describe('SqliteOfflineRepository community sqlite driver', () => { await expect(options.createEncryptionKey?.()).resolves.toBe('first-install-secret'); }); - it('partition scopeのoutboxを単一transactionで削除する', async () => { + it('partition scopeのcursorだけを単一transactionで削除しuser-scoped outboxを保持する', async () => { const repository = createRepository(); await repository.initialize(); await repository.clearScope({ userId: 7, scopeId: '8' }); const deletes = plugin.execute.mock.calls .map(([options]) => options as { statement: string; values?: unknown[] }) .filter(({ statement }) => statement.startsWith('DELETE FROM')); - expect(deletes).toHaveLength(2); - expect(deletes[0]?.values).toEqual([7, '8']); - expect(deletes[1]?.values).toEqual([7, '8']); + expect(deletes).toHaveLength(1); + expect(deletes[0]?.values).toEqual([canonicalOfflinePrincipalId(7), '8']); expect(plugin.beginTransaction).toHaveBeenCalledOnce(); expect(plugin.commitTransaction).toHaveBeenCalledOnce(); expect(plugin.rollbackTransaction).not.toHaveBeenCalled(); @@ -261,7 +278,8 @@ describe('SqliteOfflineRepository community sqlite driver', () => { scopeId: '10', commandId: 'cmd-z', aggregateType: 'test_items', - aggregateLocalId: '019d-aaaa', + sourceKey: 'test_items', + identity: { kind: 'generated', localId: '019d-aaaa' }, operation: 'test_items.update', payload: {}, optimisticValue: {}, @@ -278,7 +296,8 @@ describe('SqliteOfflineRepository community sqlite driver', () => { scopeId: '10', commandId: 'cmd-a', aggregateType: 'test_items', - aggregateLocalId: '019d-aaaa', + sourceKey: 'test_items', + identity: { kind: 'generated', localId: '019d-aaaa' }, operation: 'test_items.update', payload: {}, optimisticValue: {}, @@ -308,83 +327,6 @@ describe('SqliteOfflineRepository community sqlite driver', () => { expect(userQuery?.statement).toBe('SELECT * FROM offline_sync_commands WHERE user_id = ? ORDER BY created_at ASC, command_id ASC'); }); - it('v4 SQLiteをopenするとcommand mutationとentity visibilityをlosslessに追加する', async () => { - plugin.query.mockImplementation(async ({ statement }: { statement: string }) => { - if (statement.includes('offline_replica_schema_metadata')) { - return { columns: ['version', 'schema_hash'], rows: [[replicaSchemaV1.version, replicaSchemaV1Hash]] }; - } - if (statement === 'PRAGMA table_info(offline_sync_commands)') { - return { rows: [{ name: 'aggregate_local_id' }, { name: 'optimistic_value_json' }] }; - } - if (statement === 'PRAGMA table_info(test_items)') return { rows: [{ name: 'local_id' }] }; - return { rows: [] }; - }); - - await createRepository().initialize(); - - const statements = plugin.execute.mock.calls.map(([options]) => (options as { statement: string }).statement); - expect(statements).toContain("ALTER TABLE offline_sync_commands ADD COLUMN replica_mutation TEXT NOT NULL DEFAULT 'upsert'"); - expect(statements).toContain("ALTER TABLE test_items ADD COLUMN _offline_visibility TEXT NOT NULL DEFAULT 'present'"); - }); - - it.each([ - { - label: 'command replica_mutation ALTER', - failureStatement: 'ALTER TABLE offline_sync_commands ADD COLUMN replica_mutation', - }, - { - label: 'core metadata v5 update', - failureStatement: 'INSERT INTO offline_metadata', - }, - { - label: 'product entity visibility ALTER', - failureStatement: 'ALTER TABLE test_items ADD COLUMN _offline_visibility', - }, - ])('$labelが一度失敗してもreopenでv5 repairを完遂する', async ({ failureStatement }) => { - const commandColumns = new Set(['aggregate_local_id', 'optimistic_value_json']); - const entityColumns = new Set(['local_id']); - let coreMetadataVersion = 4; - let failOnce = true; - plugin.query.mockImplementation(async ({ statement }: { statement: string }) => { - if (statement.includes('offline_replica_schema_metadata')) { - return { columns: ['version', 'schema_hash'], rows: [[replicaSchemaV1.version, replicaSchemaV1Hash]] }; - } - if (statement === 'PRAGMA table_info(offline_sync_commands)') { - return { rows: [...commandColumns].map((name) => ({ name })) }; - } - if (statement === 'PRAGMA table_info(test_items)') { - return { rows: [...entityColumns].map((name) => ({ name })) }; - } - return { rows: [] }; - }); - plugin.execute.mockImplementation(async ({ statement, values }: { statement: string; values?: unknown[] }) => { - if (failOnce && statement.startsWith(failureStatement)) { - failOnce = false; - throw new Error(`injected ${failureStatement} failure`); - } - if (statement.startsWith('ALTER TABLE offline_sync_commands ADD COLUMN replica_mutation')) { - commandColumns.add('replica_mutation'); - } - if (statement.startsWith('INSERT INTO offline_metadata')) { - coreMetadataVersion = values?.[0] as number; - } - if (statement.startsWith('ALTER TABLE test_items ADD COLUMN _offline_visibility')) { - entityColumns.add('_offline_visibility'); - } - return {}; - }); - - await expect(createRepository().initialize()).rejects.toThrow(`injected ${failureStatement} failure`); - TestBed.resetTestingModule(); - await expect(createRepository().initialize()).resolves.toBeUndefined(); - - expect(commandColumns).toContain('replica_mutation'); - expect(coreMetadataVersion).toBe(5); - expect(entityColumns).toContain('_offline_visibility'); - const statements = plugin.execute.mock.calls.map(([options]) => (options as { statement: string }).statement); - expect(statements.filter((statement) => statement.startsWith(failureStatement))).toHaveLength(2); - }); - it('delete command persists replica_mutation in the SQLite outbox row', async () => { const repository = createRepository(); await repository.initialize(); @@ -393,7 +335,8 @@ describe('SqliteOfflineRepository community sqlite driver', () => { scopeId: '10', commandId: 'delete-command', aggregateType: 'test_items', - aggregateLocalId: 'delete-uuid', + sourceKey: 'test_items', + identity: { kind: 'generated', localId: 'delete-uuid' }, operation: 'test_items.delete', payload: { id: 42 }, optimisticValue: { title: 'confirmed' }, @@ -423,8 +366,7 @@ describe('SqliteOfflineRepository community sqlite driver', () => { userId: 1, scopeId: '10', sourceKey: 'test_items', - localId: '019d-aaaa', - serverId: null, + identity: { kind: 'generated', localId: '019d-aaaa', remoteId: null }, values: { id: 0, title: 'Local item' }, confirmedValues: null, serverRevision: null, @@ -447,47 +389,6 @@ describe('SqliteOfflineRepository community sqlite driver', () => { expect(plugin.commitTransaction).not.toHaveBeenCalled(); }); - it('optimistic_value_jsonのADD後にbackfillが失敗しても次回openで再試行する', async () => { - let hasOptimisticValueColumn = false; - let failBackfill = true; - plugin.query.mockImplementation(async ({ statement }: { statement: string }) => { - if (statement.includes('offline_replica_schema_metadata')) { - return { - columns: ['version', 'schema_hash'], - rows: [[replicaSchemaV1.version, replicaSchemaV1Hash]], - }; - } - if (statement.startsWith('PRAGMA table_info')) { - return { - rows: [{ name: 'aggregate_local_id' }, ...(hasOptimisticValueColumn ? [{ name: 'optimistic_value_json' }] : [])], - }; - } - return { rows: [] }; - }); - plugin.execute.mockImplementation(async ({ statement }: { statement: string }) => { - if (statement.startsWith('ALTER TABLE offline_sync_commands ADD COLUMN optimistic_value_json')) { - hasOptimisticValueColumn = true; - } - if (statement.startsWith('UPDATE offline_sync_commands SET optimistic_value_json') && failBackfill) { - failBackfill = false; - throw new Error('injected backfill failure'); - } - return {}; - }); - - await expect(createRepository().initialize()).rejects.toThrow('injected backfill failure'); - TestBed.resetTestingModule(); - await expect(createRepository().initialize()).resolves.toBeUndefined(); - - const statements = plugin.execute.mock.calls.map(([options]) => (options as { statement: string }).statement); - expect( - statements.filter((statement) => statement.startsWith('ALTER TABLE offline_sync_commands ADD COLUMN optimistic_value_json')), - ).toHaveLength(1); - expect(statements.filter((statement) => statement.startsWith('UPDATE offline_sync_commands SET optimistic_value_json'))).toHaveLength( - 2, - ); - }); - describe('offline replica schema initialization', () => { it('first install creates product tables and stores metadata in one transaction', async () => { storedReplicaMetadata = null; @@ -674,7 +575,6 @@ describe('SqliteOfflineRepository replica rows', () => { 'feed_key', ]; const naturalFavoriteColumns = [ - 'local_id', '_offline_user_id', '_offline_scope_id', '_offline_confirmed_json', @@ -691,6 +591,14 @@ describe('SqliteOfflineRepository replica rows', () => { return tableName === 'test_group_items' ? [...stored.values] : [...stored.values]; } + function generatedStoredRowKey(tableName: string, values: readonly unknown[]): string { + return `${tableName}:${String(values[1])}:${tableName === 'test_group_items' ? String(values[2]) : 'user'}:${String(values[0])}`; + } + + function storedGeneratedRow(localId: string, tableName = 'test_items') { + return Object.values(storedReplicaRows).find((stored) => stored.tableName === tableName && stored.values[0] === localId); + } + function queryStoredReplicaRows(tableName: string, statement: string, values?: unknown[]) { const columns = tableName === 'test_group_items' @@ -702,13 +610,13 @@ describe('SqliteOfflineRepository replica rows', () => { : testItemColumns; const entries = Object.entries(storedReplicaRows).filter(([, stored]) => stored.tableName === tableName); if (statement.includes('server_id = ?')) { - const serverId = values?.[0]; + const remoteId = values?.[0]; const userId = values?.[1]; const scopeId = tableName === 'test_group_items' ? values?.[2] : undefined; const stored = Object.entries(storedReplicaRows).find(([, row]) => { if (row.tableName !== tableName) return false; - const serverIdIndex = tableName === 'test_group_items' ? 3 : 2; - if (row.values[serverIdIndex] !== serverId || row.values[1] !== userId) return false; + const remoteIdIndex = tableName === 'test_group_items' ? 3 : 2; + if (row.values[remoteIdIndex] !== remoteId || row.values[1] !== userId) return false; if (scopeId !== undefined && row.values[2] !== scopeId) return false; return true; }); @@ -716,11 +624,29 @@ describe('SqliteOfflineRepository replica rows', () => { return { columns, rows: [replicaRowMatrix(tableName, stored[1])] }; } if (statement.includes('local_id = ?')) { - const localId = values?.[0]; - const stored = typeof localId === 'string' ? storedReplicaRows[localId] : undefined; + const localId = values?.[tableName === 'test_group_items' ? 2 : 1]; + const userId = values?.[0]; + const scopeId = tableName === 'test_group_items' ? values?.[1] : undefined; + const stored = Object.values(storedReplicaRows).find( + (candidate) => + candidate.tableName === tableName && + candidate.values[0] === localId && + candidate.values[1] === userId && + (scopeId === undefined || candidate.values[2] === scopeId), + ); if (!stored || stored.tableName !== tableName) return { rows: [] }; return { columns, rows: [replicaRowMatrix(tableName, stored)] }; } + if (tableName === 'natural_favorites' && statement.includes('fav_from = ?')) { + const stored = entries.find( + ([, row]) => + row.values[0] === values?.[0] && + row.values[1] === values?.[1] && + row.values.at(-3) === values?.[2] && + row.values.at(-2) === values?.[3], + ); + return stored ? { columns, rows: [replicaRowMatrix(tableName, stored[1])] } : { rows: [] }; + } const userId = values?.[0]; const scopeId = tableName === 'test_group_items' ? values?.[1] : undefined; const rows = entries @@ -753,34 +679,49 @@ describe('SqliteOfflineRepository replica rows', () => { const userId = values?.[0]; const scopeId = values?.[1]; const cursor = values?.[2]; - if (typeof userId === 'number' && typeof scopeId === 'string' && typeof cursor === 'string') { + if (typeof userId === 'string' && typeof scopeId === 'string' && typeof cursor === 'string') { storedReplicaCursors[`${userId}:${scopeId}`] = cursor; } } if (statement.startsWith('DELETE FROM offline_replica_cursors')) { const userId = values?.[0]; const scopeId = values?.[1]; - if (typeof userId === 'number' && scopeId === undefined) { + if (typeof userId === 'string' && scopeId === undefined) { for (const key of Object.keys(storedReplicaCursors)) { if (key.startsWith(`${userId}:`)) delete storedReplicaCursors[key]; } - } else if (typeof userId === 'number' && typeof scopeId === 'string') { + } else if (typeof userId === 'string' && typeof scopeId === 'string') { delete storedReplicaCursors[`${userId}:${scopeId}`]; } } - for (const tableName of ['test_items', 'test_group_items', 'local_projections', 'natural_favorites'] as const) { + for (const tableName of ['test_items', 'test_group_items', 'local_projections', 'natural_favorites', 'text_id_items'] as const) { if (statement.startsWith(`INSERT INTO ${tableName}`)) { - const localId = values?.[0]; - if (typeof localId === 'string') { - storedReplicaRows[localId] = { tableName, statement, values: [...(values ?? [])] }; + if (tableName !== 'natural_favorites' && tableName !== 'local_projections') { + const remoteIdIndex = tableName === 'test_group_items' ? 3 : 2; + const remoteId = values?.[remoteIdIndex]; + const collision = Object.values(storedReplicaRows).some( + (stored) => + stored.tableName === tableName && + stored.values[remoteIdIndex] === remoteId && + remoteId != null && + stored.values[1] === values?.[1] && + (tableName !== 'test_group_items' || stored.values[2] === values?.[2]) && + stored.values[0] !== values?.[0], + ); + if (collision) throw new Error(`UNIQUE constraint failed: ${tableName}.server_id`); } + const rowKey = + tableName === 'natural_favorites' + ? `natural:${JSON.stringify([values?.[0], values?.[1], values?.at(-3), values?.at(-2)])}` + : generatedStoredRowKey(tableName, values ?? []); + if (typeof rowKey === 'string') storedReplicaRows[rowKey] = { tableName, statement, values: [...(values ?? [])] }; } if (statement.startsWith(`DELETE FROM ${tableName}`)) { const userId = values?.[0]; const scopeId = values?.[1]; if ( statement.includes('_offline_user_id = ? AND _offline_scope_id = ?') && - typeof userId === 'number' && + typeof userId === 'string' && typeof scopeId === 'string' ) { for (const [localId, stored] of Object.entries(storedReplicaRows)) { @@ -791,8 +732,12 @@ describe('SqliteOfflineRepository replica rows', () => { } continue; } - const localId = values?.[0]; - if (typeof localId === 'string') delete storedReplicaRows[localId]; + const localId = values?.at(-1); + for (const [storedKey, stored] of Object.entries(storedReplicaRows)) { + if (stored.tableName !== tableName || stored.values[0] !== localId || stored.values[1] !== userId) continue; + if (tableName === 'test_group_items' && stored.values[2] !== scopeId) continue; + delete storedReplicaRows[storedKey]; + } } } }), @@ -808,10 +753,10 @@ describe('SqliteOfflineRepository replica rows', () => { const userId = values?.[0]; const scopeId = values?.[1]; const cursor = - typeof userId === 'number' && typeof scopeId === 'string' ? storedReplicaCursors[`${userId}:${scopeId}`] : undefined; + typeof userId === 'string' && typeof scopeId === 'string' ? storedReplicaCursors[`${userId}:${scopeId}`] : undefined; return cursor === undefined ? { rows: [] } : { columns: ['cursor'], rows: [[cursor]] }; } - for (const tableName of ['test_items', 'test_group_items', 'local_projections', 'natural_favorites'] as const) { + for (const tableName of ['test_items', 'test_group_items', 'local_projections', 'natural_favorites', 'text_id_items'] as const) { if (statement.startsWith(`SELECT * FROM ${tableName}`)) { return queryStoredReplicaRows(tableName, statement, values); } @@ -840,8 +785,7 @@ describe('SqliteOfflineRepository replica rows', () => { userId: 1, scopeId: '10', sourceKey: 'test_items', - localId: '019d-bbbb', - serverId: null, + identity: { kind: 'generated', localId: '019d-bbbb', remoteId: null }, values: { id: 0, title: 'Local item' }, confirmedValues: null, serverRevision: null, @@ -855,7 +799,8 @@ describe('SqliteOfflineRepository replica rows', () => { scopeId: '10', commandId: 'create-row-1', aggregateType: 'test_items', - aggregateLocalId: '019d-bbbb', + sourceKey: 'test_items', + identity: { kind: 'generated', localId: '019d-bbbb' }, operation: 'test_items.create', payload: { title: 'Local item' }, optimisticValue: { id: 0, title: 'Local item' }, @@ -879,7 +824,8 @@ describe('SqliteOfflineRepository replica rows', () => { )?.[0] as { statement: string; values?: unknown[] }; expect(upsert?.statement).toContain('title'); expect(upsert?.statement).not.toContain('value_json'); - expect(upsert?.values).toEqual(['019d-bbbb', 1, null, null, null, 'pending', 'present', 1, 'Local item']); + expect(upsert?.statement).toContain('ON CONFLICT(_offline_user_id, local_id)'); + expect(upsert?.values).toEqual(['019d-bbbb', canonicalOfflinePrincipalId(1), null, null, null, 'pending', 'present', 1, 'Local item']); expect( plugin.execute.mock.calls.some(([options]) => (options as { statement: string }).statement.startsWith('INSERT INTO offline_sync_commands'), @@ -887,7 +833,7 @@ describe('SqliteOfflineRepository replica rows', () => { ).toBe(true); }); - it('local-only projectionをserver_idなしのDDL/SQLでround-tripしserverId lookupはnullを返す', async () => { + it('local-only projectionをserver_idなしのDDL/SQLでround-tripしremoteId lookupはnullを返す', async () => { storedReplicaMetadata = null; const repository = createRepository(localProjectionSchema); await repository.initialize(); @@ -902,8 +848,7 @@ describe('SqliteOfflineRepository replica rows', () => { { ...scope, sourceKey: 'local_projections', - localId: 'feed-home', - serverId: null, + identity: { kind: 'local', localId: 'feed-home' }, values: { feedKey: 'home' }, confirmedValues: { feedKey: 'home' }, serverRevision: null, @@ -919,12 +864,11 @@ describe('SqliteOfflineRepository replica rows', () => { expect(upsert?.statement).not.toContain('server_id'); await expect(repository.getReplicaRows(scope, 'local_projections')).resolves.toEqual([ expect.objectContaining({ - localId: 'feed-home', - serverId: null, + identity: { kind: 'local', localId: 'feed-home' }, values: { feedKey: 'home' }, }), ]); - await expect(repository.getReplicaRowByServerId(scope, 'local_projections', 1)).resolves.toBeNull(); + await expect(repository.getReplicaRowByRemoteId(scope, 'local_projections', 1)).resolves.toBeNull(); expect( plugin.query.mock.calls.some( ([options]) => @@ -934,7 +878,7 @@ describe('SqliteOfflineRepository replica rows', () => { ).toBe(false); }); - it('local-only projectionへ非null serverIdを渡すと同じ契約でrejectする', async () => { + it('local-only projectionへgenerated identityを渡すとrejectする', async () => { storedReplicaMetadata = { version: localProjectionSchema.version, schemaHash: await sha256OfflineReplicaSchema(localProjectionSchema), @@ -948,8 +892,7 @@ describe('SqliteOfflineRepository replica rows', () => { userId: 1, scopeId: '10', sourceKey: 'local_projections', - localId: 'feed-home', - serverId: 1, + identity: { kind: 'generated', localId: 'feed-home', remoteId: 1 }, values: { feedKey: 'home' }, confirmedValues: null, serverRevision: null, @@ -958,11 +901,11 @@ describe('SqliteOfflineRepository replica rows', () => { }, ], }), - ).rejects.toThrow('Offline replica source "local_projections" does not define a serverId field.'); - expect(storedReplicaRows['feed-home']).toBeUndefined(); + ).rejects.toThrow('Offline replica source "local_projections" requires local identity.'); + expect(storedGeneratedRow('feed-home', 'local_projections')).toBeUndefined(); }); - it('confirmed JSONはserverId列を投影したdomain valuesだけを永続化する', async () => { + it('confirmed JSONはremoteId列を投影したdomain valuesだけを永続化する', async () => { const repository = createRepository(); await repository.initialize(); await repository.transactReplica({ @@ -971,8 +914,7 @@ describe('SqliteOfflineRepository replica rows', () => { userId: 1, scopeId: '10', sourceKey: 'test_items', - localId: '019d-confirmed', - serverId: 42, + identity: { kind: 'generated', localId: '019d-confirmed', remoteId: 42 }, values: { id: 42, title: 'Optimistic' }, confirmedValues: { id: 42, title: 'Confirmed' }, serverRevision: 1, @@ -986,7 +928,7 @@ describe('SqliteOfflineRepository replica rows', () => { (options as { statement: string }).statement.startsWith('INSERT INTO test_items'), )?.[0] as { statement: string; values?: unknown[] }; expect(upsert?.values?.[3]).toBe(JSON.stringify({ title: 'Confirmed' })); - await expect(repository.getReplicaRowByServerId({ userId: 1, scopeId: '10' }, 'test_items', 42)).resolves.toMatchObject({ + await expect(repository.getReplicaRowByRemoteId({ userId: 1, scopeId: '10' }, 'test_items', 42)).resolves.toMatchObject({ values: { title: 'Optimistic' }, confirmedValues: { title: 'Confirmed' }, }); @@ -998,30 +940,74 @@ describe('SqliteOfflineRepository replica rows', () => { const scope = { userId: 1, scopeId: '10' }; const baseRow = { ...scope, - sourceKey: 'test_items', - localId: '019d-bbbb', + sourceKey: 'test_items' as const, + identity: generatedReplicaIdentity('019d-bbbb', null), confirmedValues: null, serverRevision: null, fetchedAt: 1, syncState: 'pending' as const, }; await repository.transactReplica({ - putRows: [{ ...baseRow, serverId: null, values: { id: 0, title: 'Local item' } }], + putRows: [{ ...baseRow, values: { id: 0, title: 'Local item' } }], }); await repository.transactReplica({ - putRows: [{ ...baseRow, serverId: 38142, values: { id: 38142, title: 'Local item' }, syncState: 'confirmed' }], + putRows: [ + { + ...baseRow, + identity: generatedReplicaIdentity('019d-bbbb', 38142), + values: { id: 38142, title: 'Local item' }, + syncState: 'confirmed', + }, + ], }); - const stored = storedReplicaRows['019d-bbbb']; + const stored = storedGeneratedRow('019d-bbbb'); expect(stored?.values[0]).toBe('019d-bbbb'); expect(stored?.values[2]).toBe(38142); - await expect(repository.getReplicaRow(scope, 'test_items', '019d-bbbb')).resolves.toMatchObject({ - localId: '019d-bbbb', - serverId: 38142, + await expect(repository.getReplicaRow(scope, 'test_items', generatedCommandIdentity('019d-bbbb'))).resolves.toMatchObject({ + identity: { kind: 'generated', localId: '019d-bbbb', remoteId: 38142 }, values: { title: 'Local item' }, }); }); + it('TEXT generated idをSQLiteでnullからUUIDへ割り当て、lookup・collision・restartを保つ', async () => { + storedReplicaMetadata = { + version: textIdSchema.version, + schemaHash: await sha256OfflineReplicaSchema(textIdSchema), + }; + let repository = createRepository(textIdSchema); + await repository.initialize(); + const scope = { userId: 1, scopeId: '10' }; + const localId = 'text-local-id'; + const remoteId = '018f6f6e-74ad-7cc4-b94f-4af0b13c4401'; + const row = (nextRemoteId: string | null, nextLocalId = localId): OfflineReplicaRow => ({ + ...scope, + sourceKey: 'text_id_items', + identity: generatedReplicaIdentity(nextLocalId, nextRemoteId), + values: { id: nextRemoteId ?? '', title: nextLocalId }, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'pending', + }); + await repository.transactReplica({ putRows: [row(null)] }); + await repository.transactReplica({ putRows: [row(remoteId)] }); + await expect(repository.getReplicaRowByRemoteId(scope, 'text_id_items', remoteId)).resolves.toMatchObject({ + identity: { kind: 'generated', localId, remoteId }, + }); + await expect(repository.transactReplica({ putRows: [row(remoteId, 'another-local-id')] })).rejects.toThrow('UNIQUE constraint failed'); + await expect(repository.getReplicaRowByRemoteId(scope, 'text_id_items', 42)).rejects.toThrow( + 'generated remote id must be a non-empty string', + ); + + TestBed.resetTestingModule(); + repository = createRepository(textIdSchema); + await repository.initialize(); + await expect(repository.getReplicaRowByRemoteId(scope, 'text_id_items', remoteId)).resolves.toMatchObject({ + identity: { kind: 'generated', localId, remoteId }, + }); + }); + it('invalid/missing domain fieldsは拒否してrollbackする', async () => { const repository = createRepository(); await repository.initialize(); @@ -1032,8 +1018,7 @@ describe('SqliteOfflineRepository replica rows', () => { userId: 1, scopeId: '10', sourceKey: 'test_items', - localId: '019d-bbbb', - serverId: null, + identity: { kind: 'generated', localId: '019d-bbbb', remoteId: null }, values: { id: 0 }, confirmedValues: null, serverRevision: null, @@ -1043,9 +1028,9 @@ describe('SqliteOfflineRepository replica rows', () => { ], }), ).rejects.toThrow('Replica row is missing required source key "title".'); - expect(plugin.rollbackTransaction).toHaveBeenCalledOnce(); + expect(plugin.rollbackTransaction).not.toHaveBeenCalled(); expect(plugin.commitTransaction).not.toHaveBeenCalled(); - expect(storedReplicaRows['019d-bbbb']).toBeUndefined(); + expect(storedGeneratedRow('019d-bbbb')).toBeUndefined(); }); it('getReplicaRowはSQLite列をdecodeしてvaluesを返す', async () => { @@ -1057,8 +1042,7 @@ describe('SqliteOfflineRepository replica rows', () => { userId: 1, scopeId: '10', sourceKey: 'test_items', - localId: '019d-bbbb', - serverId: null, + identity: { kind: 'generated', localId: '019d-bbbb', remoteId: null }, values: { id: 0, title: 'Decoded title' }, confirmedValues: null, serverRevision: null, @@ -1067,7 +1051,9 @@ describe('SqliteOfflineRepository replica rows', () => { }, ], }); - await expect(repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'test_items', '019d-bbbb')).resolves.toMatchObject({ + await expect( + repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'test_items', generatedCommandIdentity('019d-bbbb')), + ).resolves.toMatchObject({ values: { title: 'Decoded title' }, fetchedAt: 99, syncState: 'pending', @@ -1076,8 +1062,8 @@ describe('SqliteOfflineRepository replica rows', () => { describe('getReplicaRows', () => { const baseRow = { - sourceKey: 'test_items', - serverId: null, + sourceKey: 'test_items' as const, + identity: generatedReplicaIdentity('placeholder', null), confirmedValues: null, serverRevision: null, fetchedAt: 1, @@ -1089,14 +1075,18 @@ describe('SqliteOfflineRepository replica rows', () => { await repository.initialize(); await repository.transactReplica({ putRows: [ - { ...baseRow, userId: 1, scopeId: '10', localId: '019d-cccc', values: { id: 0, title: 'C' } }, - { ...baseRow, userId: 1, scopeId: '10', localId: '019d-aaaa', values: { id: 0, title: 'A' } }, - { ...baseRow, userId: 1, scopeId: '10', localId: '019d-bbbb', values: { id: 0, title: 'B' } }, + { ...baseRow, userId: 1, scopeId: '10', identity: generatedReplicaIdentity('019d-cccc', null), values: { id: 0, title: 'C' } }, + { ...baseRow, userId: 1, scopeId: '10', identity: generatedReplicaIdentity('019d-aaaa', null), values: { id: 0, title: 'A' } }, + { ...baseRow, userId: 1, scopeId: '10', identity: generatedReplicaIdentity('019d-bbbb', null), values: { id: 0, title: 'B' } }, ], }); const rows = await repository.getReplicaRows({ userId: 1, scopeId: '10' }, 'test_items'); - expect(rows.map((row) => row.localId)).toEqual(['019d-aaaa', '019d-bbbb', '019d-cccc']); + expect(rows.map((row) => (row.identity.kind === 'generated' ? row.identity.localId : ''))).toEqual([ + '019d-aaaa', + '019d-bbbb', + '019d-cccc', + ]); }); it('user-scoped sourceはscopeIdを無視して同一userの行を返す', async () => { @@ -1104,22 +1094,28 @@ describe('SqliteOfflineRepository replica rows', () => { await repository.initialize(); await repository.transactReplica({ putRows: [ - { ...baseRow, userId: 1, scopeId: '10', localId: '019d-aaaa', values: { id: 0, title: 'G10' } }, - { ...baseRow, userId: 1, scopeId: '11', localId: '019d-bbbb', values: { id: 0, title: 'G11' } }, - { ...baseRow, userId: 2, scopeId: '10', localId: '019d-cccc', values: { id: 0, title: 'Other user' } }, + { ...baseRow, userId: 1, scopeId: '10', identity: generatedReplicaIdentity('019d-aaaa', null), values: { id: 0, title: 'G10' } }, + { ...baseRow, userId: 1, scopeId: '11', identity: generatedReplicaIdentity('019d-bbbb', null), values: { id: 0, title: 'G11' } }, + { + ...baseRow, + userId: 2, + scopeId: '10', + identity: generatedReplicaIdentity('019d-cccc', null), + values: { id: 0, title: 'Other user' }, + }, ], }); const rows = await repository.getReplicaRows({ userId: 1, scopeId: '10' }, 'test_items'); - expect(rows.map((row) => row.localId)).toEqual(['019d-aaaa', '019d-bbbb']); + expect(rows.map((row) => (row.identity.kind === 'generated' ? row.identity.localId : ''))).toEqual(['019d-aaaa', '019d-bbbb']); }); it('partition-scoped sourceはscopeId一致の行だけを返す', async () => { const repository = createRepository(); await repository.initialize(); const groupRow = { - sourceKey: 'test_group_items', - serverId: null, + sourceKey: 'test_group_items' as const, + identity: generatedReplicaIdentity('placeholder', null), confirmedValues: null, serverRevision: null, fetchedAt: 1, @@ -1127,21 +1123,62 @@ describe('SqliteOfflineRepository replica rows', () => { }; await repository.transactReplica({ putRows: [ - { ...groupRow, userId: 1, scopeId: '10', localId: '019d-aaaa', values: { id: 0, name: 'G10' } }, - { ...groupRow, userId: 1, scopeId: '11', localId: '019d-bbbb', values: { id: 0, name: 'G11' } }, + { ...groupRow, userId: 1, scopeId: '10', identity: generatedReplicaIdentity('019d-aaaa', null), values: { id: 0, name: 'G10' } }, + { ...groupRow, userId: 1, scopeId: '11', identity: generatedReplicaIdentity('019d-bbbb', null), values: { id: 0, name: 'G11' } }, ], }); const rows = await repository.getReplicaRows({ userId: 1, scopeId: '10' }, 'test_group_items'); expect(rows).toHaveLength(1); - expect(rows[0]?.localId).toBe('019d-aaaa'); + expect(rows[0]?.identity).toEqual({ kind: 'generated', localId: '019d-aaaa', remoteId: null }); + const partitionUpserts = plugin.execute.mock.calls + .map(([options]) => options as { statement: string }) + .filter(({ statement }) => statement.startsWith('INSERT INTO test_group_items')); + expect(partitionUpserts).toHaveLength(2); + expect(partitionUpserts[0]?.statement).toContain('ON CONFLICT(_offline_user_id, _offline_scope_id, local_id)'); + }); + + it('同じlocalIdを別principalと別partitionで共存させclearScopeを分離する', async () => { + const repository = createRepository(); + await repository.initialize(); + const localId = 'same-local-id'; + const common = { + identity: generatedReplicaIdentity(localId, null), + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'pending' as const, + }; + await repository.transactReplica({ + putRows: [ + { ...common, sourceKey: 'test_items', userId: 1, scopeId: '10', values: { id: 0, title: 'User 1' } }, + { ...common, sourceKey: 'test_items', userId: 2, scopeId: '10', values: { id: 0, title: 'User 2' } }, + { ...common, sourceKey: 'test_group_items', userId: 1, scopeId: '10', values: { id: 0, name: 'Group 10' } }, + { ...common, sourceKey: 'test_group_items', userId: 1, scopeId: '11', values: { id: 0, name: 'Group 11' } }, + ], + }); + + await expect( + repository.getReplicaRow({ userId: 2, scopeId: '10' }, 'test_items', generatedCommandIdentity(localId)), + ).resolves.toMatchObject({ values: { title: 'User 2' } }); + await expect( + repository.getReplicaRow({ userId: 1, scopeId: '11' }, 'test_group_items', generatedCommandIdentity(localId)), + ).resolves.toMatchObject({ values: { name: 'Group 11' } }); + + await repository.clearScope({ userId: 1, scopeId: '10' }); + await expect( + repository.getReplicaRow({ userId: 1, scopeId: '11' }, 'test_group_items', generatedCommandIdentity(localId)), + ).resolves.toMatchObject({ values: { name: 'Group 11' } }); + await expect( + repository.getReplicaRow({ userId: 2, scopeId: '10' }, 'test_items', generatedCommandIdentity(localId)), + ).resolves.toMatchObject({ values: { title: 'User 2' } }); }); }); describe('replica pull persistence', () => { const scope = { userId: 1, scopeId: '10' }; - it('getReplicaRowByServerIdはuser scopeでscopeIdを無視してlookupする', async () => { + it('getReplicaRowByRemoteIdはuser scopeでscopeIdを無視してlookupする', async () => { const repository = createRepository(); await repository.initialize(); await repository.transactReplica({ @@ -1150,8 +1187,7 @@ describe('SqliteOfflineRepository replica rows', () => { userId: 1, scopeId: '10', sourceKey: 'test_items', - localId: '019d-aaaa', - serverId: 42, + identity: { kind: 'generated', localId: '019d-aaaa', remoteId: 42 }, values: { id: 42, title: 'G10' }, confirmedValues: null, serverRevision: null, @@ -1162,8 +1198,7 @@ describe('SqliteOfflineRepository replica rows', () => { userId: 1, scopeId: '11', sourceKey: 'test_items', - localId: '019d-bbbb', - serverId: 43, + identity: { kind: 'generated', localId: '019d-bbbb', remoteId: 43 }, values: { id: 43, title: 'G11' }, confirmedValues: null, serverRevision: null, @@ -1173,16 +1208,16 @@ describe('SqliteOfflineRepository replica rows', () => { ], }); - await expect(repository.getReplicaRowByServerId(scope, 'test_items', 42)).resolves.toMatchObject({ - localId: '019d-aaaa', + await expect(repository.getReplicaRowByRemoteId(scope, 'test_items', 42)).resolves.toMatchObject({ + identity: expect.objectContaining({ localId: '019d-aaaa' }), }); - await expect(repository.getReplicaRowByServerId(scope, 'test_items', 43)).resolves.toMatchObject({ - localId: '019d-bbbb', + await expect(repository.getReplicaRowByRemoteId(scope, 'test_items', 43)).resolves.toMatchObject({ + identity: expect.objectContaining({ localId: '019d-bbbb' }), }); - expect(await repository.getReplicaRowByServerId(scope, 'test_items', 99)).toBeNull(); + expect(await repository.getReplicaRowByRemoteId(scope, 'test_items', 99)).toBeNull(); }); - it('getReplicaRowByServerIdはpartition scopeでscopeId一致のみ返す', async () => { + it('getReplicaRowByRemoteIdはpartition scopeでscopeId一致のみ返す', async () => { const repository = createRepository(); await repository.initialize(); await repository.transactReplica({ @@ -1191,8 +1226,7 @@ describe('SqliteOfflineRepository replica rows', () => { userId: 1, scopeId: '10', sourceKey: 'test_group_items', - localId: '019d-aaaa', - serverId: 55, + identity: { kind: 'generated', localId: '019d-aaaa', remoteId: 55 }, values: { id: 55, name: 'G10' }, confirmedValues: null, serverRevision: null, @@ -1203,8 +1237,7 @@ describe('SqliteOfflineRepository replica rows', () => { userId: 1, scopeId: '11', sourceKey: 'test_group_items', - localId: '019d-bbbb', - serverId: 56, + identity: { kind: 'generated', localId: '019d-bbbb', remoteId: 56 }, values: { id: 56, name: 'G11' }, confirmedValues: null, serverRevision: null, @@ -1214,10 +1247,10 @@ describe('SqliteOfflineRepository replica rows', () => { ], }); - await expect(repository.getReplicaRowByServerId(scope, 'test_group_items', 55)).resolves.toMatchObject({ - localId: '019d-aaaa', + await expect(repository.getReplicaRowByRemoteId(scope, 'test_group_items', 55)).resolves.toMatchObject({ + identity: expect.objectContaining({ localId: '019d-aaaa' }), }); - expect(await repository.getReplicaRowByServerId(scope, 'test_group_items', 56)).toBeNull(); + expect(await repository.getReplicaRowByRemoteId(scope, 'test_group_items', 56)).toBeNull(); }); it('putCursorsはrow更新と同一SQLite transactionで原子的に永続化する', async () => { @@ -1229,8 +1262,7 @@ describe('SqliteOfflineRepository replica rows', () => { userId: 1, scopeId: '10', sourceKey: 'test_items', - localId: '019d-aaaa', - serverId: 42, + identity: { kind: 'generated', localId: '019d-aaaa', remoteId: 42 }, values: { id: 42, title: 'Pulled' }, confirmedValues: { id: 42, title: 'Pulled' }, serverRevision: 1, @@ -1244,7 +1276,7 @@ describe('SqliteOfflineRepository replica rows', () => { expect(plugin.beginTransaction).toHaveBeenCalledOnce(); expect(plugin.commitTransaction).toHaveBeenCalledOnce(); await expect(repository.getReplicaCursor(scope)).resolves.toEqual({ userId: 1, scopeId: '10', cursor: 'cursor-v1' }); - await expect(repository.getReplicaRowByServerId(scope, 'test_items', 42)).resolves.toMatchObject({ + await expect(repository.getReplicaRowByRemoteId(scope, 'test_items', 42)).resolves.toMatchObject({ values: { title: 'Pulled' }, }); }); @@ -1259,8 +1291,7 @@ describe('SqliteOfflineRepository replica rows', () => { userId: 1, scopeId: '10', sourceKey: 'test_items', - localId: '019d-aaaa', - serverId: 42, + identity: { kind: 'generated', localId: '019d-aaaa', remoteId: 42 }, values: { id: 42 }, confirmedValues: null, serverRevision: null, @@ -1271,7 +1302,7 @@ describe('SqliteOfflineRepository replica rows', () => { putCursors: [{ userId: 1, scopeId: '10', cursor: 'cursor-v1' }], }), ).rejects.toThrow('Replica row is missing required source key "title".'); - expect(plugin.rollbackTransaction).toHaveBeenCalledOnce(); + expect(plugin.rollbackTransaction).not.toHaveBeenCalled(); expect(plugin.commitTransaction).not.toHaveBeenCalled(); expect(await repository.getReplicaCursor(scope)).toBeNull(); expect(storedReplicaCursors['1:10']).toBeUndefined(); @@ -1282,7 +1313,7 @@ describe('SqliteOfflineRepository replica rows', () => { const scopeG10 = { userId: 1, scopeId: '10' }; const scopeG11 = { userId: 1, scopeId: '11' }; - it('getReplicaRow/getReplicaRowByServerIdは別scopeIdでも同一user rowを返す', async () => { + it('getReplicaRow/getReplicaRowByRemoteIdは別scopeIdでも同一user rowを返す', async () => { const repository = createRepository(); await repository.initialize(); await repository.transactReplica({ @@ -1291,8 +1322,7 @@ describe('SqliteOfflineRepository replica rows', () => { userId: 1, scopeId: '10', sourceKey: 'test_items', - localId: '019d-cross', - serverId: 42, + identity: { kind: 'generated', localId: '019d-cross', remoteId: 42 }, values: { id: 42, title: 'Shared user row' }, confirmedValues: { id: 42, title: 'Shared user row' }, serverRevision: 1, @@ -1302,12 +1332,12 @@ describe('SqliteOfflineRepository replica rows', () => { ], }); - await expect(repository.getReplicaRow(scopeG11, 'test_items', '019d-cross')).resolves.toMatchObject({ - localId: '019d-cross', + await expect(repository.getReplicaRow(scopeG11, 'test_items', generatedCommandIdentity('019d-cross'))).resolves.toMatchObject({ + identity: expect.objectContaining({ localId: '019d-cross' }), scopeId: '11', }); - await expect(repository.getReplicaRowByServerId(scopeG11, 'test_items', 42)).resolves.toMatchObject({ - localId: '019d-cross', + await expect(repository.getReplicaRowByRemoteId(scopeG11, 'test_items', 42)).resolves.toMatchObject({ + identity: expect.objectContaining({ localId: '019d-cross' }), }); }); @@ -1320,8 +1350,7 @@ describe('SqliteOfflineRepository replica rows', () => { userId: 1, scopeId: '10', sourceKey: 'test_items', - localId: '019d-user', - serverId: 42, + identity: { kind: 'generated', localId: '019d-user', remoteId: 42 }, values: { id: 42, title: 'User scoped' }, confirmedValues: { id: 42, title: 'User scoped' }, serverRevision: 1, @@ -1332,8 +1361,7 @@ describe('SqliteOfflineRepository replica rows', () => { userId: 1, scopeId: '10', sourceKey: 'test_group_items', - localId: '019d-group', - serverId: 55, + identity: { kind: 'generated', localId: '019d-group', remoteId: 55 }, values: { id: 55, name: 'Partition scoped' }, confirmedValues: { id: 55, name: 'Partition scoped' }, serverRevision: 1, @@ -1345,10 +1373,10 @@ describe('SqliteOfflineRepository replica rows', () => { await repository.clearScope(scopeG10); - await expect(repository.getReplicaRow(scopeG10, 'test_items', '019d-user')).resolves.toMatchObject({ - localId: '019d-user', + await expect(repository.getReplicaRow(scopeG10, 'test_items', generatedCommandIdentity('019d-user'))).resolves.toMatchObject({ + identity: expect.objectContaining({ localId: '019d-user' }), }); - expect(await repository.getReplicaRow(scopeG10, 'test_group_items', '019d-group')).toBeNull(); + expect(await repository.getReplicaRow(scopeG10, 'test_group_items', generatedCommandIdentity('019d-group'))).toBeNull(); }); it('naturalKey lookupはscopeと宣言順の実列predicateを使う', async () => { @@ -1364,11 +1392,11 @@ describe('SqliteOfflineRepository replica rows', () => { expect(plugin.query).toHaveBeenCalledWith({ databaseId: 'offline-db', statement: 'SELECT * FROM natural_favorites WHERE _offline_user_id = ? AND _offline_scope_id = ? AND fav_from = ? AND fav_to = ?', - values: [7, 'scope-a', 9, '42'], + values: [canonicalOfflinePrincipalId(7), 'scope-a', 9, '42'], }); }); - it('同一localIdのnaturalKey再割当をSQLite upsert前にrejectする', async () => { + it('natural identityとrow valuesの不一致をSQLite upsert前にrejectする', async () => { storedReplicaMetadata = null; const repository = createRepository(naturalFavoriteSchema); await repository.initialize(); @@ -1376,8 +1404,7 @@ describe('SqliteOfflineRepository replica rows', () => { userId: 7, scopeId: 'scope-a', sourceKey: 'natural_favorites', - localId: 'uuid-a', - serverId: null, + identity: { kind: 'natural' as const, naturalKey: { favFrom: 9, favTo: '42' } }, confirmedValues: null, serverRevision: null, fetchedAt: 1, @@ -1390,7 +1417,7 @@ describe('SqliteOfflineRepository replica rows', () => { repository.transactReplica({ putRows: [{ ...base, values: { favFrom: 10, favTo: '42', label: 'B' } }], }), - ).rejects.toThrow('Offline replica naturalKey is immutable for "natural_favorites".'); + ).rejects.toThrow('Offline replica identity naturalKey must match values for "natural_favorites".'); }); it('confirmedValuesのnaturalKeyがvaluesと異なるrowをSQLiteへ書かない', async () => { @@ -1405,8 +1432,7 @@ describe('SqliteOfflineRepository replica rows', () => { userId: 7, scopeId: 'scope-a', sourceKey: 'natural_favorites', - localId: 'uuid-mismatch', - serverId: null, + identity: { kind: 'natural', naturalKey: { favFrom: 9, favTo: '42' } }, values: { favFrom: 9, favTo: '42', label: 'optimistic' }, confirmedValues: { favFrom: 10, favTo: '42', label: 'confirmed' }, serverRevision: 1, @@ -1423,51 +1449,78 @@ describe('SqliteOfflineRepository replica rows', () => { ).toBe(false); }); - it('同一localIdのserverId再割当をSQLite upsert前にrejectする', async () => { + it('同一localIdのremoteId再割当をSQLite upsert前にrejectする', async () => { const repository = createRepository(); await repository.initialize(); const base = { userId: 1, scopeId: '10', sourceKey: 'test_items', - localId: 'uuid-a', + identity: { kind: 'generated' as const, localId: 'uuid-a', remoteId: 42 }, confirmedValues: null, serverRevision: null, fetchedAt: 1, syncState: 'confirmed' as const, }; - await repository.transactReplica({ putRows: [{ ...base, serverId: 42, values: { id: 42, title: 'A' } }] }); - await expect(repository.transactReplica({ putRows: [{ ...base, serverId: 43, values: { id: 43, title: 'B' } }] })).rejects.toThrow( - 'Offline replica serverId is immutable: current=42, incoming=43.', - ); + await repository.transactReplica({ putRows: [{ ...base, values: { id: 42, title: 'A' } }] }); + await expect( + repository.transactReplica({ + putRows: [{ ...base, identity: { ...base.identity, remoteId: 43 }, values: { id: 43, title: 'B' } }], + }), + ).rejects.toThrow('Offline replica remoteId is immutable: current=42, incoming=43.'); }); - it('明示したidentity releaseだけがSQLiteのserverIdをnullへ戻して後続createの再割当を許可する', async () => { + it('明示したidentity releaseだけがSQLiteのremoteIdをnullへ戻して後続createの再割当を許可する', async () => { const repository = createRepository(); await repository.initialize(); const base = { userId: 1, scopeId: '10', sourceKey: 'test_items', - localId: 'uuid-release', + identity: { kind: 'generated' as const, localId: 'uuid-release', remoteId: 42 }, confirmedValues: null, serverRevision: null, fetchedAt: 1, syncState: 'confirmed' as const, }; - await repository.transactReplica({ putRows: [{ ...base, serverId: 42, values: { id: 42, title: 'A' } }] }); + await repository.transactReplica({ putRows: [{ ...base, values: { id: 42, title: 'A' } }] }); await repository.transactReplica({ - putRows: [{ ...base, serverId: null, values: { id: 42, title: 'Recreate pending' }, syncState: 'pending' }], - releaseServerIds: [{ userId: 1, scopeId: '10', sourceKey: 'test_items', localId: 'uuid-release', serverId: 42 }], + putRows: [ + { + ...base, + identity: { ...base.identity, remoteId: null }, + values: { id: 42, title: 'Recreate pending' }, + syncState: 'pending', + }, + ], + releaseRemoteIds: [ + { + userId: 1, + scopeId: '10', + sourceKey: 'test_items', + identity: { kind: 'generated', localId: 'uuid-release', remoteId: 42 }, + remoteId: 42, + }, + ], }); - await expect(repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'test_items', 'uuid-release')).resolves.toMatchObject({ - serverId: null, + await expect( + repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'test_items', { + kind: 'generated', + localId: 'uuid-release', + }), + ).resolves.toMatchObject({ + identity: { remoteId: null }, }); await repository.transactReplica({ - putRows: [{ ...base, serverId: 43, values: { id: 43, title: 'Recreated' } }], + putRows: [{ ...base, identity: { ...base.identity, remoteId: 43 }, values: { id: 43, title: 'Recreated' } }], }); - await expect(repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'test_items', 'uuid-release')).resolves.toMatchObject({ - serverId: 43, + await expect( + repository.getReplicaRow({ userId: 1, scopeId: '10' }, 'test_items', { + kind: 'generated', + localId: 'uuid-release', + }), + ).resolves.toMatchObject({ + identity: { remoteId: 43 }, }); }); }); diff --git a/projects/kit/offline/src/lib/sqlite-offline-repository.ts b/projects/kit/offline/src/lib/sqlite-offline-repository.ts index 0495bd9..6f73ee9 100644 --- a/projects/kit/offline/src/lib/sqlite-offline-repository.ts +++ b/projects/kit/offline/src/lib/sqlite-offline-repository.ts @@ -1,7 +1,18 @@ import { inject, Injectable, InjectionToken } from '@angular/core'; +import { + canonicalOfflineReplicaIdentity, + canonicalOfflinePrincipalId, + commandIdentityFromReplicaIdentity, + offlineGeneratedReplicaIdentity, + offlineNaturalReplicaIdentity, + parseOfflineCommandIdentity, + parseOfflinePrincipalId, + replicaAddressFromIdentity, + serializeOfflineCommandIdentity, +} from './offline-identity'; import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; import { - assertOfflineReplicaServerId, + assertOfflineReplicaGeneratedRemoteId, assertOfflineReplicaNaturalKeyBaseline, canonicalOfflineRemoteIdentity, decodeOfflineReplicaValues, @@ -9,6 +20,7 @@ import { normalizeOfflineNaturalKey, offlineNaturalKeyFromValues, projectOfflineReplicaValues, + type OfflineGeneratedRemoteId, type OfflineReplicaEntitySchema, type OfflineReplicaRemoteIdentity, type OfflineReplicaSchemaBundle, @@ -17,10 +29,14 @@ import { import { OFFLINE_SCHEMA_VERSION, type OfflineCommand, + type OfflineCommandIdentity, + type OfflinePrincipalId, + type OfflineReplicaAddress, type OfflineReplicaCursor, + type OfflineReplicaIdentity, type OfflineReplicaRow, type OfflineReplicaRowKey, - type OfflineReplicaServerIdRelease, + type OfflineReplicaRemoteIdRelease, type OfflineRepository, type OfflineReplicaTransaction, type OfflineScope, @@ -114,18 +130,19 @@ const SCHEMA = [ `CREATE TABLE IF NOT EXISTS offline_metadata ( id INTEGER PRIMARY KEY CHECK (id = 1), schema_version INTEGER NOT NULL, - last_user_id INTEGER + last_user_id TEXT )`, `CREATE TABLE IF NOT EXISTS offline_session_manifests ( - user_id INTEGER PRIMARY KEY, + user_id TEXT PRIMARY KEY, value_json TEXT NOT NULL )`, `CREATE TABLE IF NOT EXISTS offline_sync_commands ( command_id TEXT PRIMARY KEY, - user_id INTEGER NOT NULL, + user_id TEXT NOT NULL, scope_id TEXT NOT NULL, aggregate_type TEXT NOT NULL, - aggregate_local_id TEXT NOT NULL, + source_key TEXT NOT NULL, + identity_json TEXT NOT NULL, operation TEXT NOT NULL, payload_json TEXT NOT NULL, optimistic_value_json TEXT NOT NULL, @@ -146,7 +163,7 @@ const SCHEMA = [ schema_hash TEXT NOT NULL CHECK (length(schema_hash) = 64) )`, `CREATE TABLE IF NOT EXISTS offline_replica_cursors ( - user_id INTEGER NOT NULL, + user_id TEXT NOT NULL, scope_id TEXT NOT NULL, cursor TEXT NOT NULL, PRIMARY KEY (user_id, scope_id) @@ -167,99 +184,79 @@ export class SqliteOfflineRepository implements OfflineRepository { return this.#initialization; } - async getLastUserId(): Promise { + async getLastUserId(): Promise { const rows = await this.#query('SELECT last_user_id FROM offline_metadata WHERE id = 1'); - return this.#numberOrNull(rows[0]?.['last_user_id']); + const value = this.#stringOrNull(rows[0]?.['last_user_id']); + return value === null ? null : parseOfflinePrincipalId(value); } - async setLastUserId(userId: number): Promise { + async setLastUserId(userId: OfflinePrincipalId): Promise { await this.#write( `INSERT INTO offline_metadata (id, schema_version, last_user_id) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET schema_version = excluded.schema_version, last_user_id = excluded.last_user_id`, - [OFFLINE_SCHEMA_VERSION, userId], + [OFFLINE_SCHEMA_VERSION, canonicalOfflinePrincipalId(userId)], ); } - async getSessionManifest(userId: number): Promise { - const rows = await this.#query('SELECT value_json FROM offline_session_manifests WHERE user_id = ?', [userId]); + async getSessionManifest(userId: OfflinePrincipalId): Promise { + const rows = await this.#query('SELECT value_json FROM offline_session_manifests WHERE user_id = ?', [ + canonicalOfflinePrincipalId(userId), + ]); const row = rows[0]; return row ? this.#parse(row['value_json']) : null; } - async putSessionManifest(userId: number, value: T): Promise { + async putSessionManifest(userId: OfflinePrincipalId, value: T): Promise { await this.#write( `INSERT INTO offline_session_manifests (user_id, value_json) VALUES (?, ?) ON CONFLICT(user_id) DO UPDATE SET value_json = excluded.value_json`, - [userId, JSON.stringify(value)], + [canonicalOfflinePrincipalId(userId), JSON.stringify(value)], ); } async getReplicaRow( scope: OfflineScope, sourceKey: string, - localId: string, + identity: OfflineReplicaAddress, ): Promise | null> { - const schema = this.#resolveReplicaEntitySchema(sourceKey); - const predicates = ['local_id = ?', '_offline_user_id = ?']; - const values: SQLiteValue[] = [localId, scope.userId]; - if (schema.scope === 'partition') { - predicates.push('_offline_scope_id = ?'); - values.push(scope.scopeId); - } - const rows = await this.#query(`SELECT * FROM ${schema.tableName} WHERE ${predicates.join(' AND ')}`, values); - const row = rows[0]; - if (!row || (row['_offline_visibility'] ?? 'present') === 'pending_delete') return null; - return this.#replicaRowFromSqliteRow(schema, scope, sourceKey, localId, row); + const row = await this.#queryReplicaRow(scope, sourceKey, identity, false); + return row as OfflineReplicaRow | null; } async getReplicaRowIncludingPendingDelete( scope: OfflineScope, sourceKey: string, - localId: string, + identity: OfflineReplicaAddress, ): Promise | null> { - const schema = this.#resolveReplicaEntitySchema(sourceKey); - const predicates = ['local_id = ?', '_offline_user_id = ?']; - const values: SQLiteValue[] = [localId, scope.userId]; - if (schema.scope === 'partition') { - predicates.push('_offline_scope_id = ?'); - values.push(scope.scopeId); - } - const rows = await this.#query(`SELECT * FROM ${schema.tableName} WHERE ${predicates.join(' AND ')}`, values); - const row = rows[0]; - return row ? this.#replicaRowFromSqliteRow(schema, scope, sourceKey, localId, row) : null; + const row = await this.#queryReplicaRow(scope, sourceKey, identity, true); + return row as OfflineReplicaRow | null; } async getReplicaRows(scope: OfflineScope, sourceKey: string): Promise[]> { const schema = this.#resolveReplicaEntitySchema(sourceKey); const predicates = ['_offline_user_id = ?']; - const values: SQLiteValue[] = [scope.userId]; + const values: SQLiteValue[] = [canonicalOfflinePrincipalId(scope.userId)]; if (schema.scope === 'partition') { predicates.push('_offline_scope_id = ?'); values.push(scope.scopeId); } - const rows = await this.#query(`SELECT * FROM ${schema.tableName} WHERE ${predicates.join(' AND ')} ORDER BY local_id ASC`, values); + const orderBy = + schema.identity.kind === 'naturalKey' + ? schema.identity.sourceKeys.map((key) => schema.fields.find((field) => field.sourceKey === key)!.sqliteColumnName!).join(', ') + : 'local_id ASC'; + const rows = await this.#query(`SELECT * FROM ${schema.tableName} WHERE ${predicates.join(' AND ')} ORDER BY ${orderBy}`, values); return rows .filter((row) => (row['_offline_visibility'] ?? 'present') !== 'pending_delete') - .map((row) => this.#replicaRowFromSqliteRow(schema, scope, sourceKey, this.#string(row['local_id']), row)); + .map((row) => this.#replicaRowFromSqliteRow(schema, scope, sourceKey, row)); } - async getReplicaRowByServerId( + async getReplicaRowByRemoteId( scope: OfflineScope, sourceKey: string, - serverId: number, + remoteId: OfflineGeneratedRemoteId, ): Promise | null> { - const schema = this.#resolveReplicaEntitySchema(sourceKey); - if (!this.#schemaHasServerId(schema)) return null; - const predicates = ['server_id = ?', '_offline_user_id = ?']; - const values: SQLiteValue[] = [serverId, scope.userId]; - if (schema.scope === 'partition') { - predicates.push('_offline_scope_id = ?'); - values.push(scope.scopeId); - } - const rows = await this.#query(`SELECT * FROM ${schema.tableName} WHERE ${predicates.join(' AND ')}`, values); - const row = rows[0]; - if (!row) return null; - return this.#replicaRowFromSqliteRow(schema, scope, sourceKey, this.#string(row['local_id']), row); + if (this.#resolveReplicaEntitySchema(sourceKey).identity.kind !== 'generated') return null; + return this.getReplicaRowByRemoteIdentity(scope, sourceKey, { remoteId }); } async getReplicaRowByRemoteIdentity( @@ -269,12 +266,20 @@ export class SqliteOfflineRepository implements OfflineRepository { ): Promise | null> { const schema = this.#resolveReplicaEntitySchema(sourceKey); canonicalOfflineRemoteIdentity(schema, identity); - if (schema.identity.kind === 'serverId') { - return this.getReplicaRowByServerId(scope, sourceKey, identity.serverId!); + if (schema.identity.kind === 'generated') { + const predicates = ['server_id = ?', '_offline_user_id = ?']; + const values: SQLiteValue[] = [identity.remoteId!, canonicalOfflinePrincipalId(scope.userId)]; + if (schema.scope === 'partition') { + predicates.push('_offline_scope_id = ?'); + values.push(scope.scopeId); + } + const rows = await this.#query(`SELECT * FROM ${schema.tableName} WHERE ${predicates.join(' AND ')}`, values); + const row = rows[0]; + return row ? this.#replicaRowFromSqliteRow(schema, scope, sourceKey, row) : null; } const naturalKey = normalizeOfflineNaturalKey(schema, identity.naturalKey!); const predicates = ['_offline_user_id = ?']; - const values: SQLiteValue[] = [scope.userId]; + const values: SQLiteValue[] = [canonicalOfflinePrincipalId(scope.userId)]; if (schema.scope === 'partition') { predicates.push('_offline_scope_id = ?'); values.push(scope.scopeId); @@ -286,13 +291,12 @@ export class SqliteOfflineRepository implements OfflineRepository { } const rows = await this.#query(`SELECT * FROM ${schema.tableName} WHERE ${predicates.join(' AND ')}`, values); const row = rows[0]; - if (!row) return null; - return this.#replicaRowFromSqliteRow(schema, scope, sourceKey, this.#string(row['local_id']), row); + return row ? this.#replicaRowFromSqliteRow(schema, scope, sourceKey, row) : null; } async getReplicaCursor(scope: OfflineScope): Promise { const rows = await this.#query('SELECT cursor FROM offline_replica_cursors WHERE user_id = ? AND scope_id = ?', [ - scope.userId, + canonicalOfflinePrincipalId(scope.userId), scope.scopeId, ]); const row = rows[0]; @@ -303,14 +307,14 @@ export class SqliteOfflineRepository implements OfflineRepository { async getCommands(scope: OfflineScope): Promise { const rows = await this.#query( 'SELECT * FROM offline_sync_commands WHERE user_id = ? AND scope_id = ? ORDER BY created_at ASC, command_id ASC', - [scope.userId, scope.scopeId], + [canonicalOfflinePrincipalId(scope.userId), scope.scopeId], ); return rows.map((row) => this.#command(row)); } - async getCommandsForUser(userId: number): Promise { + async getCommandsForUser(userId: OfflinePrincipalId): Promise { const rows = await this.#query('SELECT * FROM offline_sync_commands WHERE user_id = ? ORDER BY created_at ASC, command_id ASC', [ - userId, + canonicalOfflinePrincipalId(userId), ]); return rows.map((row) => this.#command(row)); } @@ -327,22 +331,33 @@ export class SqliteOfflineRepository implements OfflineRepository { await this.#write('DELETE FROM offline_sync_commands WHERE command_id = ?', [commandId]); } - async clearUser(userId: number): Promise { + async clearUser(userId: OfflinePrincipalId): Promise { await this.#transaction(async (database) => { - await this.#execute(database, 'DELETE FROM offline_session_manifests WHERE user_id = ?', [userId]); - await this.#execute(database, 'DELETE FROM offline_sync_commands WHERE user_id = ?', [userId]); - await this.#execute(database, 'DELETE FROM offline_replica_cursors WHERE user_id = ?', [userId]); + const principal = canonicalOfflinePrincipalId(userId); + await this.#execute(database, 'DELETE FROM offline_session_manifests WHERE user_id = ?', [principal]); + await this.#execute(database, 'DELETE FROM offline_sync_commands WHERE user_id = ?', [principal]); + await this.#execute(database, 'DELETE FROM offline_replica_cursors WHERE user_id = ?', [principal]); for (const entity of this.#options.replicaSchema.entities) { - await this.#execute(database, `DELETE FROM ${entity.tableName} WHERE _offline_user_id = ?`, [userId]); + await this.#execute(database, `DELETE FROM ${entity.tableName} WHERE _offline_user_id = ?`, [principal]); } - await this.#execute(database, 'UPDATE offline_metadata SET last_user_id = NULL WHERE id = 1 AND last_user_id = ?', [userId]); + await this.#execute(database, 'UPDATE offline_metadata SET last_user_id = NULL WHERE id = 1 AND last_user_id = ?', [principal]); }); } async clearScope(scope: OfflineScope): Promise { await this.#transaction(async (database) => { - const values = [scope.userId, scope.scopeId]; - await this.#execute(database, 'DELETE FROM offline_sync_commands WHERE user_id = ? AND scope_id = ?', values); + const values = [canonicalOfflinePrincipalId(scope.userId), scope.scopeId]; + const partitionSourceKeys = this.#options.replicaSchema.entities + .filter((entity) => entity.scope === 'partition') + .map((entity) => entity.sourceKey); + if (partitionSourceKeys.length > 0) { + await this.#execute( + database, + `DELETE FROM offline_sync_commands + WHERE user_id = ? AND scope_id = ? AND source_key IN (${partitionSourceKeys.map(() => '?').join(', ')})`, + [...values, ...partitionSourceKeys], + ); + } await this.#execute(database, 'DELETE FROM offline_replica_cursors WHERE user_id = ? AND scope_id = ?', values); for (const entity of this.#options.replicaSchema.entities) { if (entity.scope !== 'partition') continue; @@ -352,14 +367,17 @@ export class SqliteOfflineRepository implements OfflineRepository { } async transactReplica(transaction: OfflineReplicaTransaction): Promise { + for (const row of transaction.putRows ?? []) this.#validateReplicaRow(row); await this.#transaction(async (databaseId) => { - const releases = new Map(); - for (const release of transaction.releaseServerIds ?? []) { - if (!Number.isSafeInteger(release.serverId) || release.serverId <= 0) { - throw new Error(`Offline replica release has invalid serverId ${String(release.serverId)}.`); - } + const releases = new Map(); + for (const release of transaction.releaseRemoteIds ?? []) { + this.#assertValidReleaseRemoteId(release.remoteId); const key = this.#replicaRowKey(release); - if (releases.has(key)) throw new Error(`Offline replica serverId release is duplicated for ${release.sourceKey}/${release.localId}.`); + if (releases.has(key)) { + throw new Error( + `Offline replica remoteId release is duplicated for ${release.sourceKey}/${canonicalOfflineReplicaIdentity(release.identity)}.`, + ); + } releases.set(key, release); } const consumedReleases = new Set(); @@ -370,7 +388,7 @@ export class SqliteOfflineRepository implements OfflineRepository { if (release) consumedReleases.add(key); } if (consumedReleases.size !== releases.size) { - throw new Error('Offline replica serverId release must match an existing row in putRows.'); + throw new Error('Offline replica remoteId release must match an existing row in putRows.'); } for (const row of transaction.removeRows ?? []) await this.#removeReplicaRow(databaseId, row); for (const command of transaction.putCommands ?? []) await this.#putCommand(databaseId, command); @@ -389,42 +407,21 @@ export class SqliteOfflineRepository implements OfflineRepository { }); this.#databaseId = databaseId; for (const statement of SCHEMA) await this.#execute(databaseId, statement); - let commandColumns = await this.#queryDatabase(databaseId, 'PRAGMA table_info(offline_sync_commands)'); - if (commandColumns.some((column) => column['name'] === 'aggregate_id')) { - await this.#execute(databaseId, 'ALTER TABLE offline_sync_commands RENAME COLUMN aggregate_id TO aggregate_local_id'); - commandColumns = await this.#queryDatabase(databaseId, 'PRAGMA table_info(offline_sync_commands)'); - } - if (!commandColumns.some((column) => column['name'] === 'optimistic_value_json')) { - await this.#execute(databaseId, 'ALTER TABLE offline_sync_commands ADD COLUMN optimistic_value_json TEXT'); - } - if (!commandColumns.some((column) => column['name'] === 'replica_mutation')) { - await this.#execute( - databaseId, - "ALTER TABLE offline_sync_commands ADD COLUMN replica_mutation TEXT NOT NULL DEFAULT 'upsert'", - ); - } - // Keep this repair outside the ADD-column branch so a process/database - // failure after ALTER but before backfill is retried on every open. - await this.#execute( - databaseId, - 'UPDATE offline_sync_commands SET optimistic_value_json = payload_json WHERE optimistic_value_json IS NULL', - ); - await this.#execute( - databaseId, - `INSERT INTO offline_metadata (id, schema_version, last_user_id) VALUES (1, ?, NULL) - ON CONFLICT(id) DO UPDATE SET schema_version = excluded.schema_version`, - [OFFLINE_SCHEMA_VERSION], - ); - await this.#initializeReplicaSchema(databaseId); - for (const entity of this.#options.replicaSchema.entities) { - const columns = await this.#queryDatabase(databaseId, `PRAGMA table_info(${entity.tableName})`); - if (!columns.some((column) => column['name'] === '_offline_visibility')) { - await this.#execute( - databaseId, - `ALTER TABLE ${entity.tableName} ADD COLUMN _offline_visibility TEXT NOT NULL DEFAULT 'present'`, + const metadata = await this.#queryDatabase(databaseId, 'SELECT schema_version FROM offline_metadata WHERE id = 1'); + if (metadata.length === 0) { + await this.#execute(databaseId, 'INSERT INTO offline_metadata (id, schema_version, last_user_id) VALUES (1, ?, NULL)', [ + OFFLINE_SCHEMA_VERSION, + ]); + } else { + const storedVersion = this.#number(metadata[0]!['schema_version']); + if (storedVersion !== OFFLINE_SCHEMA_VERSION) { + throw new Error( + `Unsupported offline storage schema version ${storedVersion}; expected ${OFFLINE_SCHEMA_VERSION}. ` + + 'A lossless core schema migration is required before this database can be opened.', ); } } + await this.#initializeReplicaSchema(databaseId); } async #initializeReplicaSchema(databaseId: string): Promise { @@ -541,10 +538,11 @@ export class SqliteOfflineRepository implements OfflineRepository { #command(row: SQLiteRow): OfflineCommand { return { commandId: this.#string(row['command_id']), - userId: this.#number(row['user_id']), + userId: parseOfflinePrincipalId(this.#string(row['user_id'])), scopeId: this.#string(row['scope_id']), aggregateType: this.#string(row['aggregate_type']), - aggregateLocalId: this.#string(row['aggregate_local_id']), + sourceKey: this.#string(row['source_key']), + identity: parseOfflineCommandIdentity(this.#parse(row['identity_json'])), operation: this.#string(row['operation']), payload: this.#parse(row['payload_json']), optimisticValue: this.#parse(row['optimistic_value_json']), @@ -563,22 +561,24 @@ export class SqliteOfflineRepository implements OfflineRepository { return this.#execute( databaseId, `INSERT INTO offline_sync_commands - (command_id, user_id, scope_id, aggregate_type, aggregate_local_id, operation, payload_json, optimistic_value_json, + (command_id, user_id, scope_id, aggregate_type, source_key, identity_json, operation, payload_json, optimistic_value_json, replica_mutation, payload_hash, base_revision_json, state, attempts, retry_at, created_at, last_error_code) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(command_id) DO UPDATE SET user_id = excluded.user_id, scope_id = excluded.scope_id, aggregate_type = excluded.aggregate_type, - aggregate_local_id = excluded.aggregate_local_id, operation = excluded.operation, payload_json = excluded.payload_json, + source_key = excluded.source_key, + identity_json = excluded.identity_json, operation = excluded.operation, payload_json = excluded.payload_json, optimistic_value_json = excluded.optimistic_value_json, replica_mutation = excluded.replica_mutation, payload_hash = excluded.payload_hash, base_revision_json = excluded.base_revision_json, state = excluded.state, attempts = excluded.attempts, retry_at = excluded.retry_at, created_at = excluded.created_at, last_error_code = excluded.last_error_code`, [ command.commandId, - command.userId, + canonicalOfflinePrincipalId(command.userId), command.scopeId, command.aggregateType, - command.aggregateLocalId, + command.sourceKey, + serializeOfflineCommandIdentity(command.identity), command.operation, JSON.stringify(command.payload), JSON.stringify(command.optimisticValue), @@ -594,46 +594,116 @@ export class SqliteOfflineRepository implements OfflineRepository { ); } - async #putReplicaRow( - databaseId: string, - row: OfflineReplicaRow, - release: OfflineReplicaServerIdRelease | undefined, - ): Promise { + async #queryReplicaRow( + scope: OfflineScope, + sourceKey: string, + identity: OfflineReplicaAddress, + includePendingDelete: boolean, + ): Promise { + const schema = this.#resolveReplicaEntitySchema(sourceKey); + const predicates = ['_offline_user_id = ?']; + const values: SQLiteValue[] = [canonicalOfflinePrincipalId(scope.userId)]; + if (schema.scope === 'partition') { + predicates.push('_offline_scope_id = ?'); + values.push(scope.scopeId); + } + if (identity.kind === 'generated' || identity.kind === 'local') { + if ( + (identity.kind === 'generated' && schema.identity.kind !== 'generated') || + (identity.kind === 'local' && schema.identity.kind !== 'localOnly') + ) { + return null; + } + predicates.push('local_id = ?'); + values.push(identity.localId); + } else { + const naturalKey = normalizeOfflineNaturalKey(schema, identity.naturalKey); + for (const sourceKeyPart of schema.identity.sourceKeys) { + const field = schema.fields.find((candidate) => candidate.sourceKey === sourceKeyPart)!; + predicates.push(`${field.sqliteColumnName!} = ?`); + values.push(naturalKey[sourceKeyPart]!); + } + } + const rows = await this.#query(`SELECT * FROM ${schema.tableName} WHERE ${predicates.join(' AND ')}`, values); + const row = rows[0]; + if (!row || (!includePendingDelete && (row['_offline_visibility'] ?? 'present') === 'pending_delete')) return null; + return this.#replicaRowFromSqliteRow(schema, scope, sourceKey, row); + } + + async #putReplicaRow(databaseId: string, row: OfflineReplicaRow, release: OfflineReplicaRemoteIdRelease | undefined): Promise { const schema = this.#resolveReplicaEntitySchema(row.sourceKey); - assertOfflineReplicaServerId(schema, row.serverId); + if (row.identity.kind === 'generated') { + assertOfflineReplicaGeneratedRemoteId(schema, row.identity.remoteId); + } + if (schema.identity.kind === 'naturalKey') { + if (row.identity.kind !== 'natural') { + throw new Error(`Offline replica source "${row.sourceKey}" requires natural identity.`); + } + const valuesIdentity = offlineNaturalKeyFromValues(schema, row.values)!; + if ( + canonicalOfflineRemoteIdentity(schema, { naturalKey: row.identity.naturalKey }) !== + canonicalOfflineRemoteIdentity(schema, { naturalKey: valuesIdentity }) + ) { + throw new Error(`Offline replica identity naturalKey must match values for "${schema.sourceKey}".`); + } + } const encoded = encodeOfflineReplicaValues(schema, row.values); if (row.confirmedValues !== null) encodeOfflineReplicaValues(schema, row.confirmedValues); assertOfflineReplicaNaturalKeyBaseline(schema, row.values, row.confirmedValues); - const existing = await this.getReplicaRowIncludingPendingDelete(row, row.sourceKey, row.localId); + const existing = await this.#queryReplicaRow(row, row.sourceKey, replicaAddressFromIdentity(row.identity), true); if (!existing && release) { - throw new Error(`Offline replica serverId release requires an existing row for ${row.sourceKey}/${row.localId}.`); + throw new Error( + `Offline replica remoteId release requires an existing row for ${row.sourceKey}/${canonicalOfflineReplicaIdentity(row.identity)}.`, + ); } if (existing) this.#assertReplicaIdentityAssignment(schema, existing, row, release); const confirmedValues = row.confirmedValues === null ? null : projectOfflineReplicaValues(schema, row.confirmedValues); const { sql, domainColumns } = this.#buildReplicaUpsertStatement(schema); - const values: SQLiteValue[] = [ - row.localId, - row.userId, - ...(schema.scope === 'partition' ? [row.scopeId] : []), - ...(this.#schemaHasServerId(schema) ? [row.serverId] : []), + const values: SQLiteValue[] = []; + if (schema.identity.kind === 'generated' || schema.identity.kind === 'localOnly') { + const expectedKind = schema.identity.kind === 'generated' ? 'generated' : 'local'; + if (row.identity.kind !== expectedKind) { + throw new Error(`Offline replica source "${row.sourceKey}" requires ${expectedKind} identity.`); + } + values.push(row.identity.localId); + } + values.push(canonicalOfflinePrincipalId(row.userId)); + if (schema.scope === 'partition') values.push(row.scopeId); + if (schema.identity.kind === 'generated') { + if (row.identity.kind !== 'generated') { + throw new Error(`Offline replica source "${row.sourceKey}" requires generated identity.`); + } + values.push(row.identity.remoteId); + } + values.push( confirmedValues === null ? null : JSON.stringify(confirmedValues), row.serverRevision == null ? null : JSON.stringify(row.serverRevision), row.syncState, row.visibility ?? 'present', row.fetchedAt, ...domainColumns.map((column) => encoded[column] ?? null), - ]; + ); await this.#execute(databaseId, sql, values); } #removeReplicaRow(databaseId: string, key: OfflineReplicaRowKey): Promise { const schema = this.#resolveReplicaEntitySchema(key.sourceKey); - const predicates = ['local_id = ?', '_offline_user_id = ?']; - const values: SQLiteValue[] = [key.localId, key.userId]; + const predicates = ['_offline_user_id = ?']; + const values: SQLiteValue[] = [canonicalOfflinePrincipalId(key.userId)]; if (schema.scope === 'partition') { predicates.push('_offline_scope_id = ?'); values.push(key.scopeId); } + if (key.identity.kind === 'generated' || key.identity.kind === 'local') { + predicates.push('local_id = ?'); + values.push(key.identity.localId); + } else { + for (const sourceKeyPart of schema.identity.sourceKeys) { + const field = schema.fields.find((candidate) => candidate.sourceKey === sourceKeyPart)!; + predicates.push(`${field.sqliteColumnName!} = ?`); + values.push(key.identity.naturalKey[sourceKeyPart]!); + } + } return this.#execute(databaseId, `DELETE FROM ${schema.tableName} WHERE ${predicates.join(' AND ')}`, values); } @@ -642,7 +712,7 @@ export class SqliteOfflineRepository implements OfflineRepository { databaseId, `INSERT INTO offline_replica_cursors (user_id, scope_id, cursor) VALUES (?, ?, ?) ON CONFLICT(user_id, scope_id) DO UPDATE SET cursor = excluded.cursor`, - [cursor.userId, cursor.scopeId, cursor.cursor], + [canonicalOfflinePrincipalId(cursor.userId), cursor.scopeId, cursor.cursor], ); } @@ -650,15 +720,27 @@ export class SqliteOfflineRepository implements OfflineRepository { sql: string; domainColumns: readonly string[]; } { - const insertColumns = ['local_id', '_offline_user_id']; + const insertColumns = ['_offline_user_id']; const updateSets = ['_offline_user_id = excluded._offline_user_id']; + const conflictTarget: string[] = ['_offline_user_id']; if (schema.scope === 'partition') { insertColumns.push('_offline_scope_id'); updateSets.push('_offline_scope_id = excluded._offline_scope_id'); + conflictTarget.push('_offline_scope_id'); } - if (this.#schemaHasServerId(schema)) { + if (schema.identity.kind === 'generated') { + insertColumns.unshift('local_id'); insertColumns.push('server_id'); updateSets.push('server_id = excluded.server_id'); + conflictTarget.push('local_id'); + } else if (schema.identity.kind === 'naturalKey') { + for (const sourceKey of schema.identity.sourceKeys) { + const field = schema.fields.find((candidate) => candidate.sourceKey === sourceKey)!; + conflictTarget.push(field.sqliteColumnName!); + } + } else { + insertColumns.unshift('local_id'); + conflictTarget.push('local_id'); } insertColumns.push( '_offline_confirmed_json', @@ -685,7 +767,7 @@ export class SqliteOfflineRepository implements OfflineRepository { return { sql: `INSERT INTO ${schema.tableName} (${insertColumns.join(', ')}) VALUES (${placeholders}) - ON CONFLICT(local_id) DO UPDATE SET ${updateSets.join(', ')}`, + ON CONFLICT(${conflictTarget.join(', ')}) DO UPDATE SET ${updateSets.join(', ')}`, domainColumns, }; } @@ -694,14 +776,13 @@ export class SqliteOfflineRepository implements OfflineRepository { schema: OfflineReplicaEntitySchema>, scope: OfflineScope, sourceKey: string, - localId: string, row: SQLiteRow, ): OfflineReplicaRow { + const identity = this.#identityFromSqliteRow(schema, row); return { ...scope, sourceKey, - localId, - serverId: this.#schemaHasServerId(schema) ? this.#numberOrNull(row['server_id']) : null, + identity, values: decodeOfflineReplicaValues(schema, row) as TValues, confirmedValues: this.#parseNullable(row['_offline_confirmed_json']), serverRevision: this.#parseNullable(row['_offline_server_revision_json']), @@ -711,55 +792,121 @@ export class SqliteOfflineRepository implements OfflineRepository { }; } + #identityFromSqliteRow(schema: OfflineReplicaEntitySchema>, row: SQLiteRow): OfflineReplicaIdentity { + if (schema.identity.kind === 'naturalKey') { + return offlineNaturalReplicaIdentity(schema, decodeOfflineReplicaValues(schema, row)); + } + const localId = this.#string(row['local_id']); + if (schema.identity.kind === 'localOnly') return { kind: 'local', localId }; + const remoteId = row['server_id']; + if (remoteId == null) return offlineGeneratedReplicaIdentity(localId, null); + if (schema.identity.kind === 'generated' && schema.identity.affinity === 'TEXT') { + return offlineGeneratedReplicaIdentity(localId, this.#string(remoteId)); + } + return offlineGeneratedReplicaIdentity(localId, this.#number(remoteId)); + } + #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}".`); return schema; } - #schemaHasServerId(schema: OfflineReplicaEntitySchema>): boolean { - return schema.identity.kind === 'serverId'; + #validateReplicaRow(row: OfflineReplicaRow): void { + const schema = this.#resolveReplicaEntitySchema(row.sourceKey); + if (schema.identity.kind === 'localOnly') { + if (row.identity.kind !== 'local') { + throw new Error(`Offline replica source "${schema.sourceKey}" requires local identity.`); + } + } else if (schema.identity.kind === 'generated') { + if (row.identity.kind !== 'generated') { + throw new Error(`Offline replica source "${schema.sourceKey}" requires generated identity.`); + } + assertOfflineReplicaGeneratedRemoteId(schema, row.identity.remoteId); + } else { + if (row.identity.kind !== 'natural') { + throw new Error(`Offline replica source "${schema.sourceKey}" requires natural identity.`); + } + const fromValues = offlineNaturalKeyFromValues(schema, row.values)!; + if ( + canonicalOfflineRemoteIdentity(schema, { naturalKey: row.identity.naturalKey }) !== + canonicalOfflineRemoteIdentity(schema, { naturalKey: fromValues }) + ) { + throw new Error(`Offline replica identity naturalKey must match values for "${schema.sourceKey}".`); + } + } + encodeOfflineReplicaValues(schema, row.values); + if (row.confirmedValues !== null) encodeOfflineReplicaValues(schema, row.confirmedValues); + assertOfflineReplicaNaturalKeyBaseline(schema, row.values, row.confirmedValues); } #replicaRowKey(row: OfflineReplicaRowKey): string { const schema = this.#resolveReplicaEntitySchema(row.sourceKey); - return `${row.userId}:${schema.scope === 'user' ? 'user' : row.scopeId}:${row.sourceKey}:${row.localId}`; + return `${canonicalOfflinePrincipalId(row.userId)}:${schema.scope === 'user' ? 'user' : row.scopeId}:${row.sourceKey}:${canonicalOfflineReplicaIdentity(row.identity)}`; } #assertReplicaIdentityAssignment( schema: OfflineReplicaEntitySchema>, existing: OfflineReplicaRow, incoming: OfflineReplicaRow, - release: OfflineReplicaServerIdRelease | undefined, + release: OfflineReplicaRemoteIdRelease | undefined, ): void { - if (release && schema.identity.kind !== 'serverId') { - throw new Error(`Offline replica serverId release is unsupported for source "${incoming.sourceKey}".`); + if (release && schema.identity.kind !== 'generated') { + throw new Error(`Offline replica remoteId release is unsupported for source "${incoming.sourceKey}".`); + } + if (schema.identity.kind === 'localOnly') { + if (existing.identity.kind !== 'local' || incoming.identity.kind !== 'local') { + throw new Error(`Offline replica local identity is required for "${incoming.sourceKey}".`); + } + if (existing.identity.localId !== incoming.identity.localId) { + throw new Error(`Offline replica localId is immutable for "${schema.sourceKey}".`); + } + return; } - if (schema.identity.kind === 'serverId') { + if (schema.identity.kind === 'generated') { + if (existing.identity.kind !== 'generated' || incoming.identity.kind !== 'generated') { + throw new Error(`Offline replica generated identity is required for "${incoming.sourceKey}".`); + } if (release) { - if (existing.serverId !== release.serverId || incoming.serverId !== null) { + if (existing.identity.remoteId !== release.remoteId || incoming.identity.remoteId !== null) { throw new Error( - `Offline replica serverId release must transition current=${existing.serverId} to incoming=null for ${incoming.sourceKey}/${incoming.localId}.`, + `Offline replica remoteId release must transition current=${String(existing.identity.remoteId)} to incoming=null for ${incoming.sourceKey}/${incoming.identity.localId}.`, ); } return; } - if (existing.serverId !== null && existing.serverId !== incoming.serverId) { - throw new Error(`Offline replica serverId is immutable: current=${existing.serverId}, incoming=${String(incoming.serverId)}.`); + if (existing.identity.localId !== incoming.identity.localId) { + throw new Error(`Offline replica localId is immutable for "${schema.sourceKey}".`); + } + if (existing.identity.remoteId !== null && existing.identity.remoteId !== incoming.identity.remoteId) { + throw new Error( + `Offline replica remoteId is immutable: current=${String(existing.identity.remoteId)}, incoming=${String(incoming.identity.remoteId)}.`, + ); } return; } if (schema.identity.kind === 'naturalKey') { - const current = canonicalOfflineRemoteIdentity(schema, { - naturalKey: offlineNaturalKeyFromValues(schema, existing.values)!, - }); - const next = canonicalOfflineRemoteIdentity(schema, { - naturalKey: offlineNaturalKeyFromValues(schema, incoming.values)!, - }); + if (existing.identity.kind !== 'natural' || incoming.identity.kind !== 'natural') { + throw new Error(`Offline replica natural identity is required for "${schema.sourceKey}".`); + } + const current = canonicalOfflineRemoteIdentity(schema, { naturalKey: existing.identity.naturalKey }); + const next = canonicalOfflineRemoteIdentity(schema, { naturalKey: incoming.identity.naturalKey }); if (current !== next) throw new Error(`Offline replica naturalKey is immutable for "${schema.sourceKey}".`); } } + #assertValidReleaseRemoteId(remoteId: import('./offline-replica-schema').OfflineGeneratedRemoteId): void { + if (typeof remoteId === 'number') { + if (!Number.isSafeInteger(remoteId) || remoteId <= 0) { + throw new Error(`Offline replica release has invalid remoteId ${String(remoteId)}.`); + } + return; + } + if (typeof remoteId !== 'string' || remoteId.length === 0) { + throw new Error(`Offline replica release has invalid remoteId ${String(remoteId)}.`); + } + } + async #execute(databaseId: string, statement: string, values: SQLiteValue[] = []): Promise { await this.#sqlite!.execute({ databaseId, statement, values }); } diff --git a/projects/kit/offline/src/public-api.ts b/projects/kit/offline/src/public-api.ts index 06e76d9..a1a1c58 100644 --- a/projects/kit/offline/src/public-api.ts +++ b/projects/kit/offline/src/public-api.ts @@ -1,5 +1,6 @@ /** Standard scoped local replica and outbox runtime for offline-capable Ionic applications. */ export * from './lib/offline-replica-schema'; +export * from './lib/offline-identity'; export * from './lib/offline-replica-puller'; export * from './lib/offline-replica-pull.service'; export * from './lib/offline-command-executor'; diff --git a/projects/kit/offline/type-tests/nonstrict-null.ts b/projects/kit/offline/type-tests/nonstrict-null.ts index 5508775..9cf81d8 100644 --- a/projects/kit/offline/type-tests/nonstrict-null.ts +++ b/projects/kit/offline/type-tests/nonstrict-null.ts @@ -3,7 +3,7 @@ import { defineReplicaEntity, integer, nullable, - serverId, + generatedId, text, } from '../../../../dist/kit/types/rdlabo-ionic-angular-kit-offline'; @@ -21,7 +21,7 @@ const entity = defineReplicaEntity()({ sourceKey: 'nonstrict_items', scope: 'user', fields: { - id: serverId(), + id: generatedId('integer'), title: text(), subtitle: nullable(text()), sortOrder: integer(), @@ -39,7 +39,7 @@ defineReplicaEntity()({ sourceKey: 'nonstrict_invalid_items', scope: 'user', fields: { - id: serverId(), + id: generatedId('integer'), // @ts-expect-error Primitive affinity checks remain active without strictNullChecks. title: integer(), subtitle: nullable(text()), @@ -53,7 +53,7 @@ defineReplicaEntity()({ scope: 'user', // @ts-expect-error Exact-key validation still rejects a missing select property. fields: { - id: serverId(), + id: generatedId('integer'), title: text(), subtitle: nullable(text()), }, @@ -65,7 +65,7 @@ defineReplicaEntity()({ scope: 'user', // @ts-expect-error Exact-key validation still rejects an unknown property. fields: { - id: serverId(), + id: generatedId('integer'), title: text(), subtitle: nullable(text()), sortOrder: integer(),