Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 17 additions & 9 deletions projects/kit/offline/src/lib/offline-replica-schema.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Select>()({
table: 'items',
sourceKey: 'items',
scope: 'user',
fields: { title: text() },
const schema = defineReplicaEntity<Select>()({
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');
});
});

Expand Down
15 changes: 12 additions & 3 deletions projects/kit/offline/src/lib/offline-replica-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TSelect extends Record<string, unknown>>() {
return function defineReplicaEntityConfig<
Expand Down Expand Up @@ -217,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<Record<string, unknown>>, 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 };
Expand All @@ -233,8 +242,8 @@ function buildOfflineReplicaEntitySchema<TSelect extends Record<string, unknown>

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);
Expand Down
70 changes: 70 additions & 0 deletions projects/kit/offline/src/lib/offline-repository.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TestItemSelect>()({
table: 'test_items',
Expand Down Expand Up @@ -56,6 +57,21 @@ const testGroupItemEntity = defineReplicaEntity<{ id: number; name: string }>()(
},
});

const localProjectionEntity = defineReplicaEntity<LocalProjectionSelect>()({
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],
Expand Down Expand Up @@ -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: {} });
Expand Down
6 changes: 3 additions & 3 deletions projects/kit/offline/src/lib/offline-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
Expand All @@ -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}.`);
}
}

Expand Down
116 changes: 111 additions & 5 deletions projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TestItemSelect>()({
table: 'test_items',
Expand Down Expand Up @@ -53,6 +54,21 @@ const testGroupItemEntity = defineReplicaEntity<{ id: number; name: string }>()(
},
});

const localProjectionEntity = defineReplicaEntity<LocalProjectionSelect>()({
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],
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -1065,7 +1171,7 @@ describe('SqliteOfflineRepository replica rows', () => {
});
});

function createRepository(): SqliteOfflineRepository {
function createRepository(replicaSchema: OfflineReplicaSchemaBundle = replicaSchemaV1WithGroup): SqliteOfflineRepository {
TestBed.configureTestingModule({
providers: [
SqliteOfflineRepository,
Expand All @@ -1075,7 +1181,7 @@ describe('SqliteOfflineRepository replica rows', () => {
useValue: {
databaseName: 'test-offline',
createEncryptionKey: async () => 'secret',
replicaSchema: replicaSchemaV1WithGroup,
replicaSchema,
},
},
],
Expand Down
2 changes: 2 additions & 0 deletions projects/kit/offline/src/lib/sqlite-offline-repository.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { inject, Injectable, InjectionToken } from '@angular/core';
import { OFFLINE_KIT_OPTIONS } from './offline-kit-options';
import {
assertOfflineReplicaServerId,
decodeOfflineReplicaValues,
encodeOfflineReplicaValues,
projectOfflineReplicaValues,
Expand Down Expand Up @@ -500,6 +501,7 @@ export class SqliteOfflineRepository implements OfflineRepository {

#putReplicaRow(databaseId: string, row: OfflineReplicaRow): Promise<void> {
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);
Expand Down