From 632a605c3dfce3db569cbb570178d9e18621bc71 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Fri, 24 Jul 2026 06:55:52 +0900 Subject: [PATCH 1/2] feat: support local-only offline projections --- .../src/lib/offline-replica-schema.spec.ts | 26 ++++++++++++------- .../offline/src/lib/offline-replica-schema.ts | 7 ++--- 2 files changed, 21 insertions(+), 12 deletions(-) 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 22064b3..a56e1c1 100644 --- a/projects/kit/offline/src/lib/offline-replica-schema.spec.ts +++ b/projects/kit/offline/src/lib/offline-replica-schema.spec.ts @@ -204,19 +204,27 @@ describe('offline-replica-schema runtime', () => { scope: 'user', fields: { id: serverId(), altId: serverId() }, }), - ).toThrow('Replica entity must define exactly one serverId field.'); + ).toThrow('Replica entity must define at most one serverId field.'); }); - it('rejects zero serverId fields', () => { + it('supports a local-only projection without a serverId field', () => { type Select = { title: string }; - expect(() => - defineReplicaEntity()({ + table: 'items', + sourceKey: 'items', + scope: 'user', + fields: { title: text() }, + }); + + expect(schema.fields).toEqual([ + expect.objectContaining({ + sourceKey: 'title', + policy: 'column', + sqliteColumnName: 'title', }), - ).toThrow('Replica entity must define exactly one serverId field.'); + ]); + expect(schema.createTableSql[0]).not.toContain('server_id'); + expect(schema.schemaFingerprintInput).toContain('hasServerId=0'); }); }); diff --git a/projects/kit/offline/src/lib/offline-replica-schema.ts b/projects/kit/offline/src/lib/offline-replica-schema.ts index 80ccae6..94d51d8 100644 --- a/projects/kit/offline/src/lib/offline-replica-schema.ts +++ b/projects/kit/offline/src/lib/offline-replica-schema.ts @@ -147,7 +147,8 @@ const RESERVED_COLUMN_NAMES = new Set([ * Begins a type-safe replica entity schema definition for the given select shape. * * Every key in `TSelect` must appear exactly once in `fields`, and column nullability - * must match the select property 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. */ export function defineReplicaEntity>() { return function defineReplicaEntityConfig< @@ -233,8 +234,8 @@ function buildOfflineReplicaEntitySchema 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 exactly one serverId field.'); - const hasServerId = true; + if (serverIdCount > 1) throw new Error('Replica entity must define at most one serverId field.'); + const hasServerId = serverIdCount === 1; const fields: OfflineReplicaFieldDescriptor[] = sourceKeys.map((sourceKey) => { const fieldDef = definition.fields[sourceKey as keyof TSelect] as OfflineReplicaFieldDef; return materializeFieldDescriptor(sourceKey, fieldDef); From 3811f2935c3960e2d878a18f2d7629f62187da7b Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sat, 25 Jul 2026 07:46:01 +0900 Subject: [PATCH 2/2] fix(offline): enforce local-only replica identity --- .../offline/src/lib/offline-replica-schema.ts | 8 ++ .../src/lib/offline-repository.spec.ts | 70 +++++++++++ .../kit/offline/src/lib/offline-repository.ts | 6 +- .../src/lib/sqlite-offline-repository.spec.ts | 116 +++++++++++++++++- .../src/lib/sqlite-offline-repository.ts | 2 + 5 files changed, 194 insertions(+), 8 deletions(-) diff --git a/projects/kit/offline/src/lib/offline-replica-schema.ts b/projects/kit/offline/src/lib/offline-replica-schema.ts index 94d51d8..5583571 100644 --- a/projects/kit/offline/src/lib/offline-replica-schema.ts +++ b/projects/kit/offline/src/lib/offline-replica-schema.ts @@ -218,6 +218,14 @@ export function serverId(): OfflineReplicaServerIdDef { return { kind: 'serverId' }; } +/** Rejects a remote identity on a local-only projection before it reaches platform storage. */ +export function assertOfflineReplicaServerId(schema: OfflineReplicaEntitySchema>, value: number | null): void { + const hasServerId = schema.fields.some((field) => field.policy === 'serverId'); + if (!hasServerId && value !== null) { + throw new Error(`Offline replica source "${schema.sourceKey}" does not define a serverId field.`); + } +} + /** Excludes a source property from SQLite while retaining it in the select shape. */ export function ignored(reason: string): OfflineReplicaIgnoredDef { return { kind: 'ignored', reason }; diff --git a/projects/kit/offline/src/lib/offline-repository.spec.ts b/projects/kit/offline/src/lib/offline-repository.spec.ts index 0eed3bd..6dba969 100644 --- a/projects/kit/offline/src/lib/offline-repository.spec.ts +++ b/projects/kit/offline/src/lib/offline-repository.spec.ts @@ -24,6 +24,7 @@ import { type TestItemSelect = { id: number; title: string }; type TestItemWithSubtitleSelect = { id: number; title: string; subtitle: string }; +type LocalProjectionSelect = { feedKey: string }; const testItemEntity = defineReplicaEntity()({ table: 'test_items', @@ -56,6 +57,21 @@ const testGroupItemEntity = defineReplicaEntity<{ id: number; name: string }>()( }, }); +const localProjectionEntity = defineReplicaEntity()({ + table: 'local_projections', + sourceKey: 'local_projections', + scope: 'user', + fields: { + feedKey: text(), + }, +}); + +const localProjectionSchema = defineOfflineReplicaSchema({ + version: 1, + entities: [localProjectionEntity], + migrations: [], +}); + const replicaSchemaV1 = defineOfflineReplicaSchema({ version: 1, entities: [testItemEntity, testGroupItemEntity], @@ -820,6 +836,60 @@ describe('IonicOfflineRepository', () => { expect(await repository.getCommands(scope)).toEqual([]); }); + it('local-only projectionをserverIdなしでround-tripしserverId lookupは常にnullを返す', async () => { + repository = createRepository(localProjectionSchema); + await repository.initialize(); + const scope = { userId: 1, groupId: 10 }; + await repository.transactReplica({ + putRows: [ + { + ...scope, + sourceKey: 'local_projections', + localId: 'feed-home', + serverId: null, + values: { feedKey: 'home' }, + confirmedValues: { feedKey: 'home' }, + serverRevision: null, + fetchedAt: 1, + syncState: 'confirmed', + }, + ], + }); + + await expect(repository.getReplicaRows(scope, 'local_projections')).resolves.toEqual([ + expect.objectContaining({ + localId: 'feed-home', + serverId: null, + values: { feedKey: 'home' }, + }), + ]); + await expect(repository.getReplicaRowByServerId(scope, 'local_projections', 1)).resolves.toBeNull(); + }); + + it('local-only projectionへ非null serverIdを渡すと永続化前にrejectする', async () => { + repository = createRepository(localProjectionSchema); + await repository.initialize(); + await expect( + repository.transactReplica({ + putRows: [ + { + userId: 1, + groupId: 10, + sourceKey: 'local_projections', + localId: 'feed-home', + serverId: 1, + values: { feedKey: 'home' }, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'pending', + }, + ], + }), + ).rejects.toThrow('Offline replica source "local_projections" does not define a serverId field.'); + await expect(repository.getReplicaRows({ userId: 1, groupId: 10 }, 'local_projections')).resolves.toEqual([]); + }); + it('未知schemaではoffline領域だけ初期化し他のstorage keyを保持する', async () => { storage.values.set('offline:metadata', { schemaVersion: 999, lastUserId: 1 }); storage.values.set('offline:outbox:commands', { stale: {} }); diff --git a/projects/kit/offline/src/lib/offline-repository.ts b/projects/kit/offline/src/lib/offline-repository.ts index 1b30972..0017c83 100644 --- a/projects/kit/offline/src/lib/offline-repository.ts +++ b/projects/kit/offline/src/lib/offline-repository.ts @@ -2,6 +2,7 @@ import { inject, Injectable, InjectionToken } from '@angular/core'; import { KitStorageService } from '@rdlabo/ionic-angular-kit'; import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; import { + assertOfflineReplicaServerId, encodeOfflineReplicaValues, projectOfflineReplicaValues, sha256OfflineReplicaSchema, @@ -599,6 +600,7 @@ export class IonicOfflineRepository implements OfflineRepository { #validateReplicaRow(row: OfflineReplicaRow): void { const schema = this.#resolveReplicaEntitySchema(row.sourceKey); + assertOfflineReplicaServerId(schema, row.serverId); encodeOfflineReplicaValues(schema, row.values); if (row.confirmedValues !== null) encodeOfflineReplicaValues(schema, row.confirmedValues); } @@ -615,9 +617,7 @@ export class IonicOfflineRepository implements OfflineRepository { return schema.scope === 'user' || row.groupId === incoming.groupId; }); if (collision) { - throw new Error( - `Offline replica serverId ${String(incoming.serverId)} is already mapped to localId ${collision[1].localId}.`, - ); + throw new Error(`Offline replica serverId ${String(incoming.serverId)} is already mapped to localId ${collision[1].localId}.`); } } 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 d8043e7..576c5e6 100644 --- a/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts +++ b/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts @@ -21,6 +21,7 @@ import { type TestItemSelect = { id: number; title: string }; type TestItemWithSubtitleSelect = { id: number; title: string; subtitle: string }; +type LocalProjectionSelect = { feedKey: string }; const testItemEntity = defineReplicaEntity()({ table: 'test_items', @@ -53,6 +54,21 @@ const testGroupItemEntity = defineReplicaEntity<{ id: number; name: string }>()( }, }); +const localProjectionEntity = defineReplicaEntity()({ + table: 'local_projections', + sourceKey: 'local_projections', + scope: 'user', + fields: { + feedKey: text(), + }, +}); + +const localProjectionSchema = defineOfflineReplicaSchema({ + version: 1, + entities: [localProjectionEntity], + migrations: [], +}); + const replicaSchemaV1 = defineOfflineReplicaSchema({ version: 1, entities: [testItemEntity], @@ -482,12 +498,27 @@ describe('SqliteOfflineRepository replica rows', () => { 'name', ]; + const localProjectionColumns = [ + 'local_id', + '_offline_user_id', + '_offline_confirmed_json', + '_offline_server_revision_json', + '_offline_sync_state', + '_offline_fetched_at', + 'feed_key', + ]; + function replicaRowMatrix(tableName: string, stored: { values: unknown[] }): unknown[] { return tableName === 'test_group_items' ? [...stored.values] : [...stored.values]; } function queryStoredReplicaRows(tableName: string, statement: string, values?: unknown[]) { - const columns = tableName === 'test_group_items' ? testGroupItemColumns : testItemColumns; + const columns = + tableName === 'test_group_items' + ? testGroupItemColumns + : tableName === 'local_projections' + ? localProjectionColumns + : testItemColumns; const entries = Object.entries(storedReplicaRows).filter(([, stored]) => stored.tableName === tableName); if (statement.includes('server_id = ?')) { const serverId = values?.[0]; @@ -556,7 +587,7 @@ describe('SqliteOfflineRepository replica rows', () => { delete storedReplicaCursors[`${userId}:${groupId}`]; } } - for (const tableName of ['test_items', 'test_group_items'] as const) { + for (const tableName of ['test_items', 'test_group_items', 'local_projections'] as const) { if (statement.startsWith(`INSERT INTO ${tableName}`)) { const localId = values?.[0]; if (typeof localId === 'string') { @@ -599,7 +630,7 @@ describe('SqliteOfflineRepository replica rows', () => { typeof userId === 'number' && typeof groupId === 'number' ? storedReplicaCursors[`${userId}:${groupId}`] : undefined; return cursor === undefined ? { rows: [] } : { columns: ['cursor'], rows: [[cursor]] }; } - for (const tableName of ['test_items', 'test_group_items'] as const) { + for (const tableName of ['test_items', 'test_group_items', 'local_projections'] as const) { if (statement.startsWith(`SELECT * FROM ${tableName}`)) { return queryStoredReplicaRows(tableName, statement, values); } @@ -675,6 +706,81 @@ describe('SqliteOfflineRepository replica rows', () => { ).toBe(true); }); + it('local-only projectionをserver_idなしのDDL/SQLでround-tripしserverId lookupはnullを返す', async () => { + storedReplicaMetadata = null; + const repository = createRepository(localProjectionSchema); + await repository.initialize(); + const createTable = plugin.execute.mock.calls.find(([options]) => + (options as { statement: string }).statement.startsWith('CREATE TABLE IF NOT EXISTS local_projections'), + )?.[0] as { statement: string } | undefined; + expect(createTable?.statement).not.toContain('server_id'); + + const scope = { userId: 1, groupId: 10 }; + await repository.transactReplica({ + putRows: [ + { + ...scope, + sourceKey: 'local_projections', + localId: 'feed-home', + serverId: null, + values: { feedKey: 'home' }, + confirmedValues: { feedKey: 'home' }, + serverRevision: null, + fetchedAt: 1, + syncState: 'confirmed', + }, + ], + }); + + const upsert = plugin.execute.mock.calls.find(([options]) => + (options as { statement: string }).statement.startsWith('INSERT INTO local_projections'), + )?.[0] as { statement: string } | undefined; + expect(upsert?.statement).not.toContain('server_id'); + await expect(repository.getReplicaRows(scope, 'local_projections')).resolves.toEqual([ + expect.objectContaining({ + localId: 'feed-home', + serverId: null, + values: { feedKey: 'home' }, + }), + ]); + await expect(repository.getReplicaRowByServerId(scope, 'local_projections', 1)).resolves.toBeNull(); + expect( + plugin.query.mock.calls.some( + ([options]) => + (options as { statement: string }).statement.includes('FROM local_projections') && + (options as { statement: string }).statement.includes('server_id = ?'), + ), + ).toBe(false); + }); + + it('local-only projectionへ非null serverIdを渡すと同じ契約でrejectする', async () => { + storedReplicaMetadata = { + version: localProjectionSchema.version, + schemaHash: await sha256OfflineReplicaSchema(localProjectionSchema), + }; + const repository = createRepository(localProjectionSchema); + await repository.initialize(); + await expect( + repository.transactReplica({ + putRows: [ + { + userId: 1, + groupId: 10, + sourceKey: 'local_projections', + localId: 'feed-home', + serverId: 1, + values: { feedKey: 'home' }, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'pending', + }, + ], + }), + ).rejects.toThrow('Offline replica source "local_projections" does not define a serverId field.'); + expect(storedReplicaRows['feed-home']).toBeUndefined(); + }); + it('confirmed JSONはserverId列を投影したdomain valuesだけを永続化する', async () => { const repository = createRepository(); await repository.initialize(); @@ -1065,7 +1171,7 @@ describe('SqliteOfflineRepository replica rows', () => { }); }); - function createRepository(): SqliteOfflineRepository { + function createRepository(replicaSchema: OfflineReplicaSchemaBundle = replicaSchemaV1WithGroup): SqliteOfflineRepository { TestBed.configureTestingModule({ providers: [ SqliteOfflineRepository, @@ -1075,7 +1181,7 @@ describe('SqliteOfflineRepository replica rows', () => { useValue: { databaseName: 'test-offline', createEncryptionKey: async () => 'secret', - replicaSchema: replicaSchemaV1WithGroup, + replicaSchema, }, }, ], diff --git a/projects/kit/offline/src/lib/sqlite-offline-repository.ts b/projects/kit/offline/src/lib/sqlite-offline-repository.ts index c7ea4f8..6a47507 100644 --- a/projects/kit/offline/src/lib/sqlite-offline-repository.ts +++ b/projects/kit/offline/src/lib/sqlite-offline-repository.ts @@ -1,6 +1,7 @@ import { inject, Injectable, InjectionToken } from '@angular/core'; import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; import { + assertOfflineReplicaServerId, decodeOfflineReplicaValues, encodeOfflineReplicaValues, projectOfflineReplicaValues, @@ -500,6 +501,7 @@ export class SqliteOfflineRepository implements OfflineRepository { #putReplicaRow(databaseId: string, row: OfflineReplicaRow): Promise { const schema = this.#resolveReplicaEntitySchema(row.sourceKey); + assertOfflineReplicaServerId(schema, row.serverId); const encoded = encodeOfflineReplicaValues(schema, row.values); const confirmedValues = row.confirmedValues === null ? null : projectOfflineReplicaValues(schema, row.confirmedValues); const { sql, domainColumns } = this.#buildReplicaUpsertStatement(schema);