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
42 changes: 42 additions & 0 deletions projects/kit/offline/src/lib/offline-repository.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -842,6 +842,48 @@ describe('IonicOfflineRepository', () => {
expect((await repository.getCommandsForUser!(1)).map((item) => item.commandId)).toEqual(['cmd-a', 'cmd-m', 'cmd-z']);
});

it('legacy web outboxの送信中と複数回試行済みの最終失敗をcommit不明として安全側へnormalizeする', async () => {
const base: OfflineCommand = {
userId: 1,
scopeId: '10',
commandId: 'legacy-pending',
aggregateType: 'test_items',
sourceKey: 'test_items',
identity: { kind: 'generated', localId: 'legacy' },
operation: 'test_items.update',
payload: {},
optimisticValue: {},
payloadHash: 'hash',
baseRevision: null,
state: 'pending',
attempts: 0,
retryAt: null,
createdAt: 1,
lastErrorCode: null,
};
storage.values.set('offline:outbox:commands', {
pending: base,
sending: { ...base, commandId: 'legacy-sending', state: 'sending' },
retry: { ...base, commandId: 'legacy-retry', state: 'retry_wait' },
conflict: { ...base, commandId: 'legacy-conflict', state: 'conflict', attempts: 2 },
rejected: { ...base, commandId: 'legacy-rejected', state: 'rejected', attempts: 2 },
firstRejected: { ...base, commandId: 'legacy-first-rejected', state: 'rejected', attempts: 1 },
explicitSafe: { ...base, commandId: 'new-pretransport', state: 'retry_wait', serverCommitUnknown: false },
});

const restored = await repository.getCommands({ userId: 1, scopeId: '10' });
expect(restored).toEqual(
expect.arrayContaining([
expect.objectContaining({ commandId: 'legacy-sending', serverCommitUnknown: true }),
expect.objectContaining({ commandId: 'legacy-retry', serverCommitUnknown: true }),
expect.objectContaining({ commandId: 'legacy-conflict', serverCommitUnknown: true }),
expect.objectContaining({ commandId: 'legacy-rejected', serverCommitUnknown: true }),
expect.objectContaining({ commandId: 'new-pretransport', serverCommitUnknown: false }),
]),
);
expect(restored.find(({ commandId }) => commandId === 'legacy-first-rejected')).not.toHaveProperty('serverCommitUnknown');
});

it('outboxを作成順で保持し、scope削除時もuser-scoped commandを保持する', async () => {
const base: Omit<OfflineCommand, 'scopeId' | 'commandId' | 'createdAt'> = {
userId: 1,
Expand Down
37 changes: 36 additions & 1 deletion projects/kit/offline/src/lib/offline-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ interface OfflineCommandBase<T> extends OfflineScope {
retryAt: number | null;
createdAt: number;
lastErrorCode: string | null;
/** True when transport started but the client cannot prove whether the server committed. */
serverCommitUnknown?: boolean;
}

/** Durable before/after image used to reconcile product-owned derived rows. */
Expand Down Expand Up @@ -141,6 +143,10 @@ export interface OfflineReplicaTransaction {
putCommands?: readonly OfflineCommand[];
removeCommandIds?: readonly string[];
putCursors?: readonly OfflineReplicaCursor[];
/** Scopes whose acknowledged server changes still require an authoritative pull. */
putReconciliationScopes?: readonly OfflineScope[];
/** Scopes whose authoritative post-acknowledgement pull completed successfully. */
removeReconciliationScopes?: readonly OfflineScope[];
}

/** Durable local replica and outbox persistence contract. */
Expand Down Expand Up @@ -173,6 +179,7 @@ export interface OfflineRepository {
identity: OfflineReplicaRemoteIdentity,
): Promise<OfflineReplicaRow<TValues> | null>;
getReplicaCursor(scope: OfflineScope): Promise<OfflineReplicaCursor | null>;
getReconciliationScopes?(userId: OfflinePrincipalId): Promise<OfflineScope[]>;
getCommands(scope: OfflineScope): Promise<OfflineCommand[]>;
getCommandsForUser?(userId: OfflinePrincipalId): Promise<OfflineCommand[]>;
putCommand(command: OfflineCommand): Promise<void>;
Expand Down Expand Up @@ -218,6 +225,7 @@ const CURSORS_KEY = 'offline:replica:cursors';
const OUTBOX_KEY = 'offline:outbox:commands';
const REPLICA_TRANSACTION_KEY = 'offline:replica:transaction';
const REPLICA_SCHEMA_MIGRATION_KEY = 'offline:replica:schema-migration';
const RECONCILIATION_SCOPES_KEY = 'offline:replica:reconciliation-scopes';

function compareOfflineCommands(left: OfflineCommand, right: OfflineCommand): number {
return left.createdAt - right.createdAt || (left.commandId < right.commandId ? -1 : left.commandId > right.commandId ? 1 : 0);
Expand Down Expand Up @@ -344,12 +352,20 @@ export class IonicOfflineRepository implements OfflineRepository {
return cursor === undefined ? null : { ...scope, cursor };
}

async getReconciliationScopes(userId: OfflinePrincipalId): Promise<OfflineScope[]> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 新規に追加した公開メソッドにJSDocコメントがない

新設した公開メソッド getReconciliationScopesprojects/kit/offline/src/lib/offline-repository.ts:355)にJSDocコメントが付いておらず、AGENTS.mdの「すべての公開クラス・関数・型にJSDocコメントを付ける」という必須ルールに違反しています。
Impact: 公開APIの説明が欠け、リポジトリの記述規約に反した状態で公開されます。

該当箇所

同じ違反が SqliteOfflineRepository.getReconciliationScopesprojects/kit/offline/src/lib/sqlite-offline-repository.ts:325-330)と OfflineRepository インターフェースの getReconciliationScopes?projects/kit/offline/src/lib/offline-repository.ts:182)にもあります。AGENTS.md「When modifying this repo」3項を参照。

Suggested change
async getReconciliationScopes(userId: OfflinePrincipalId): Promise<OfflineScope[]> {
/** Returns scopes whose acknowledged server changes still require an authoritative pull. */
async getReconciliationScopes(userId: OfflinePrincipalId): Promise<OfflineScope[]> {
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

await this.initialize();
await this.#writes;
const scopes = await this.#readRecord<OfflineScope>(RECONCILIATION_SCOPES_KEY);
return Object.values(scopes).filter((scope) => scope.userId === userId);
}

async getCommands(scope: OfflineScope): Promise<OfflineCommand[]> {
await this.initialize();
await this.#writes;
const commands = await this.#readRecord<OfflineCommand>(OUTBOX_KEY);
return Object.values(commands)
.filter((command) => command.userId === scope.userId && command.scopeId === scope.scopeId)
.map((command) => this.#normalizeCommand(command))
.sort(compareOfflineCommands);
}

Expand All @@ -359,9 +375,18 @@ export class IonicOfflineRepository implements OfflineRepository {
const commands = await this.#readRecord<OfflineCommand>(OUTBOX_KEY);
return Object.values(commands)
.filter((command) => command.userId === userId)
.map((command) => this.#normalizeCommand(command))
.sort(compareOfflineCommands);
}

#normalizeCommand(command: OfflineCommand): OfflineCommand {
if (command.serverCommitUnknown !== undefined) return command;
const legacyAmbiguousFailure = command.attempts >= 2 && ['blocked_auth', 'conflict', 'rejected'].includes(command.state);
return command.state === 'sending' || command.state === 'retry_wait' || legacyAmbiguousFailure
? { ...command, serverCommitUnknown: true }
: command;
}

async putCommand(command: OfflineCommand): Promise<void> {
await this.initialize();
await this.#assertReplicaSchemaLocked();
Expand Down Expand Up @@ -393,6 +418,7 @@ export class IonicOfflineRepository implements OfflineRepository {
this.#filterRecord<OfflineReplicaRow>(ROWS_KEY, (value) => value.userId !== userId),
this.#filterRecord<OfflineCommand>(OUTBOX_KEY, (value) => value.userId !== userId),
this.#filterRecord<string>(CURSORS_KEY, (_value, key) => !key.startsWith(`${canonicalOfflinePrincipalId(userId)}:`)),
this.#filterRecord<OfflineScope>(RECONCILIATION_SCOPES_KEY, (value) => value.userId !== userId),
]);
const metadata = await this.#metadata();
if (metadata.lastUserId === userId) {
Expand All @@ -413,6 +439,7 @@ export class IonicOfflineRepository implements OfflineRepository {
return schema.scope === 'user' || !belongsToGroup(value);
}),
this.#filterRecord<string>(CURSORS_KEY, (_value, key) => key !== this.#cursorKey(scope)),
this.#filterRecord<OfflineScope>(RECONCILIATION_SCOPES_KEY, (value) => !belongsToGroup(value)),
]);
}

Expand Down Expand Up @@ -601,10 +628,11 @@ export class IonicOfflineRepository implements OfflineRepository {
async #applyReplicaTransaction(transaction: OfflineReplicaTransaction, journal: boolean): Promise<void> {
await this.#assertReplicaSchemaLocked();
for (const row of transaction.putRows ?? []) this.#validateReplicaRow(row);
const [rows, commands, cursors] = await Promise.all([
const [rows, commands, cursors, reconciliationScopes] = await Promise.all([
this.#readRecord<OfflineReplicaRow>(ROWS_KEY),
this.#readRecord<OfflineCommand>(OUTBOX_KEY),
this.#readRecord<string>(CURSORS_KEY),
this.#readRecord<OfflineScope>(RECONCILIATION_SCOPES_KEY),
]);
const identityCheckRows = { ...rows };
const releases = new Map<string, OfflineReplicaRemoteIdRelease>();
Expand Down Expand Up @@ -654,10 +682,17 @@ export class IonicOfflineRepository implements OfflineRepository {
for (const cursor of transaction.putCursors ?? []) {
cursors[this.#cursorKey(cursor)] = cursor.cursor;
}
for (const scope of transaction.putReconciliationScopes ?? []) {
reconciliationScopes[this.#cursorKey(scope)] = scope;
}
for (const scope of transaction.removeReconciliationScopes ?? []) {
delete reconciliationScopes[this.#cursorKey(scope)];
}
await Promise.all([
this.#storage.set(ROWS_KEY, rows),
this.#storage.set(OUTBOX_KEY, commands),
this.#storage.set(CURSORS_KEY, cursors),
this.#storage.set(RECONCILIATION_SCOPES_KEY, reconciliationScopes),
]);
await this.#storage.remove(REPLICA_TRANSACTION_KEY);
}
Expand Down
Loading