mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 02:41:09 +02:00
fix(mana/web): sprint 3 — type-safe sync protocol + tests
- New SyncChange / FieldChange / SyncOp types replace `any[]` in
applyServerChanges. The wire format is now self-documenting and
TypeScript catches malformed callsites at compile time.
- isValidSyncChange() validates incoming server payloads at the boundary:
malformed entries are dropped with a single warn log, valid ones are
applied. A bad row from the server can no longer corrupt IndexedDB.
Hand-rolled type guards keep us free of a runtime-validation dep.
- applyServerChanges() and readFieldTimestamps() are now top-level
exports (extracted out of createUnifiedSync's closure) so they can be
imported directly by tests. Behaviour is unchanged — the closure
variant inside the sync manager just resolves the module-level
symbol now.
- New sync.test.ts covers:
* pure isValidSyncChange and readFieldTimestamps cases
* field-level LWW: server-newer wins, split outcome when local-newer
on one field and server-newer on another
* insert with __fieldTimestamps stamping
* soft-delete LWW guard
* malformed-entry drop with valid entries surviving
* sync-loop guard: server-applied writes don't generate _pendingChanges
- fake-indexeddb added as devDependency for the integration tests.
Note: the monorepo's vitest install is currently tangled across mixed
@vitest/* package versions in the lockfile, so `pnpm test` fails before
reaching this file. The tests are written to pass on any vitest 4.x once
that's untangled — needs its own dedicated cleanup pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ce04f43248
commit
9e0ade4c0a
5 changed files with 672 additions and 220 deletions
|
|
@ -29,6 +29,7 @@
|
|||
"@vitest/coverage-v8": "^4.0.14",
|
||||
"@vitest/ui": "^4.0.14",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-svelte": "^3.3.2",
|
||||
|
|
|
|||
344
apps/mana/apps/web/src/lib/data/sync.test.ts
Normal file
344
apps/mana/apps/web/src/lib/data/sync.test.ts
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
/**
|
||||
* Tests for the sync engine.
|
||||
*
|
||||
* Two layers:
|
||||
* 1. Pure tests for the wire-format guards and helpers — no IndexedDB
|
||||
* needed, run anywhere vitest runs.
|
||||
* 2. Integration tests for `applyServerChanges` against an in-memory
|
||||
* Dexie db via `fake-indexeddb/auto`. These exercise the field-level
|
||||
* LWW logic that Sprint 1 introduced.
|
||||
*
|
||||
* NOTE on running locally: the monorepo's vitest install is currently
|
||||
* tangled across multiple `@vitest/*` versions in the lockfile (3.x and
|
||||
* 4.x mixed). The pure tests below are written so they pass on any vitest
|
||||
* 4.x; the integration block additionally needs `fake-indexeddb` (already
|
||||
* a devDependency). Once vitest is realigned, `pnpm test` should pick this
|
||||
* file up automatically — no separate config required.
|
||||
*/
|
||||
|
||||
import 'fake-indexeddb/auto';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Stub the side-effect modules the Dexie hooks reach into so importing
|
||||
// `database.ts` doesn't try to load funnel-tracking, automation triggers,
|
||||
// or inline suggestions. The hooks themselves still run; their side
|
||||
// effects are just no-ops.
|
||||
vi.mock('$lib/stores/funnel-tracking', () => ({
|
||||
trackFirstContent: vi.fn(),
|
||||
}));
|
||||
vi.mock('$lib/triggers/registry', () => ({
|
||||
fire: vi.fn(),
|
||||
}));
|
||||
vi.mock('$lib/triggers/inline-suggest', () => ({
|
||||
checkInlineSuggestion: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
import {
|
||||
isValidSyncChange,
|
||||
readFieldTimestamps,
|
||||
applyServerChanges,
|
||||
type SyncChange,
|
||||
} from './sync';
|
||||
import { db, FIELD_TIMESTAMPS_KEY } from './database';
|
||||
|
||||
// ─── Pure tests ──────────────────────────────────────────────────
|
||||
|
||||
describe('isValidSyncChange', () => {
|
||||
const baseInsert: SyncChange = {
|
||||
table: 'tasks',
|
||||
id: 'task-1',
|
||||
op: 'insert',
|
||||
data: { title: 'hello' },
|
||||
};
|
||||
|
||||
it('accepts a well-formed insert change', () => {
|
||||
expect(isValidSyncChange(baseInsert)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a well-formed update change with field timestamps', () => {
|
||||
const change: SyncChange = {
|
||||
table: 'tasks',
|
||||
id: 'task-1',
|
||||
op: 'update',
|
||||
fields: {
|
||||
title: { value: 'updated', updatedAt: '2026-04-01T10:00:00Z' },
|
||||
priority: { value: 'high', updatedAt: '2026-04-01T10:01:00Z' },
|
||||
},
|
||||
};
|
||||
expect(isValidSyncChange(change)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a delete change with deletedAt', () => {
|
||||
const change: SyncChange = {
|
||||
table: 'tasks',
|
||||
id: 'task-1',
|
||||
op: 'delete',
|
||||
deletedAt: '2026-04-01T10:00:00Z',
|
||||
};
|
||||
expect(isValidSyncChange(change)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects null and primitives', () => {
|
||||
expect(isValidSyncChange(null)).toBe(false);
|
||||
expect(isValidSyncChange(undefined)).toBe(false);
|
||||
expect(isValidSyncChange('not an object')).toBe(false);
|
||||
expect(isValidSyncChange(42)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when table is missing or empty', () => {
|
||||
expect(isValidSyncChange({ ...baseInsert, table: '' })).toBe(false);
|
||||
expect(isValidSyncChange({ ...baseInsert, table: undefined })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when id is missing or empty', () => {
|
||||
expect(isValidSyncChange({ ...baseInsert, id: '' })).toBe(false);
|
||||
expect(isValidSyncChange({ ...baseInsert, id: undefined })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects unknown op values', () => {
|
||||
expect(isValidSyncChange({ ...baseInsert, op: 'upsert' })).toBe(false);
|
||||
expect(isValidSyncChange({ ...baseInsert, op: '' })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects malformed fields map', () => {
|
||||
// Inner value is not a FieldChange object
|
||||
expect(
|
||||
isValidSyncChange({
|
||||
...baseInsert,
|
||||
op: 'update',
|
||||
fields: { title: 'just a string' },
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
// updatedAt must be a string when present
|
||||
expect(
|
||||
isValidSyncChange({
|
||||
...baseInsert,
|
||||
op: 'update',
|
||||
fields: { title: { value: 'x', updatedAt: 12345 } },
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when data is a primitive', () => {
|
||||
expect(isValidSyncChange({ ...baseInsert, data: 'not an object' })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when deletedAt is not a string', () => {
|
||||
expect(isValidSyncChange({ ...baseInsert, deletedAt: 123 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readFieldTimestamps', () => {
|
||||
it('returns the field-timestamps map when present', () => {
|
||||
const ft = { title: '2026-04-01T10:00:00Z', priority: '2026-04-01T11:00:00Z' };
|
||||
const record = { id: 'x', [FIELD_TIMESTAMPS_KEY]: ft };
|
||||
expect(readFieldTimestamps(record)).toEqual(ft);
|
||||
});
|
||||
|
||||
it('returns an empty map when the field is missing (legacy record)', () => {
|
||||
expect(readFieldTimestamps({ id: 'x' })).toEqual({});
|
||||
});
|
||||
|
||||
it('handles null and non-object inputs gracefully', () => {
|
||||
expect(readFieldTimestamps(null)).toEqual({});
|
||||
expect(readFieldTimestamps(undefined)).toEqual({});
|
||||
expect(readFieldTimestamps(42)).toEqual({});
|
||||
});
|
||||
|
||||
it('returns an empty map if __fieldTimestamps is not an object', () => {
|
||||
expect(readFieldTimestamps({ id: 'x', [FIELD_TIMESTAMPS_KEY]: 'not-a-map' })).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Integration tests against the unified Dexie db ─────────────
|
||||
|
||||
describe('applyServerChanges (Dexie integration)', () => {
|
||||
beforeEach(async () => {
|
||||
// Wipe every sync-tracked table plus the bookkeeping ones so each
|
||||
// test starts from a clean slate.
|
||||
const tables = ['tasks', '_pendingChanges', '_syncMeta'];
|
||||
for (const t of tables) {
|
||||
try {
|
||||
await db.table(t).clear();
|
||||
} catch {
|
||||
// Table may not exist in this Dexie version — ignore.
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('inserts a new record with __fieldTimestamps populated', async () => {
|
||||
await applyServerChanges('todo', [
|
||||
{
|
||||
table: 'tasks',
|
||||
id: 'task-A',
|
||||
op: 'insert',
|
||||
data: {
|
||||
id: 'task-A',
|
||||
title: 'Buy milk',
|
||||
priority: 'medium',
|
||||
isCompleted: false,
|
||||
order: 0,
|
||||
updatedAt: '2026-04-01T10:00:00Z',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const stored = await db.table('tasks').get('task-A');
|
||||
expect(stored).toBeDefined();
|
||||
expect(stored.title).toBe('Buy milk');
|
||||
const ft = readFieldTimestamps(stored);
|
||||
expect(ft.title).toBe('2026-04-01T10:00:00Z');
|
||||
expect(ft.priority).toBe('2026-04-01T10:00:00Z');
|
||||
});
|
||||
|
||||
it('field-level LWW: server wins per-field when newer', async () => {
|
||||
// Seed a local record via the regular Dexie API so the creating-hook
|
||||
// stamps it. We can't use applyServerChanges to seed because it
|
||||
// suppresses the hook; we want a *real* local record here.
|
||||
await db.table('tasks').add({
|
||||
id: 'task-B',
|
||||
title: 'old title',
|
||||
priority: 'low',
|
||||
isCompleted: false,
|
||||
order: 0,
|
||||
});
|
||||
|
||||
// Server sends an update with NEWER timestamps for both fields.
|
||||
await applyServerChanges('todo', [
|
||||
{
|
||||
table: 'tasks',
|
||||
id: 'task-B',
|
||||
op: 'update',
|
||||
fields: {
|
||||
title: { value: 'new title', updatedAt: '2099-01-01T00:00:00Z' },
|
||||
priority: { value: 'high', updatedAt: '2099-01-01T00:00:00Z' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const stored = await db.table('tasks').get('task-B');
|
||||
expect(stored.title).toBe('new title');
|
||||
expect(stored.priority).toBe('high');
|
||||
|
||||
const ft = readFieldTimestamps(stored);
|
||||
expect(ft.title).toBe('2099-01-01T00:00:00Z');
|
||||
expect(ft.priority).toBe('2099-01-01T00:00:00Z');
|
||||
});
|
||||
|
||||
it('field-level LWW: split outcome when one field is newer and one older', async () => {
|
||||
// Seed local with field timestamps slightly in the future.
|
||||
await db.table('tasks').add({
|
||||
id: 'task-C',
|
||||
title: 'local title',
|
||||
priority: 'low',
|
||||
isCompleted: false,
|
||||
order: 0,
|
||||
});
|
||||
|
||||
// Manually overwrite __fieldTimestamps so we can test the comparison
|
||||
// against precise values. Use the in-progress applyingServerChanges
|
||||
// flag indirectly by going through applyServerChanges with an insert
|
||||
// op that overwrites field timestamps. Easier: just patch via update
|
||||
// which the hook will handle by merging.
|
||||
await db.table('tasks').update('task-C', {
|
||||
title: 'local title v2',
|
||||
priority: 'urgent',
|
||||
});
|
||||
|
||||
// Now apply a server change where:
|
||||
// - title server timestamp is OLDER → local wins
|
||||
// - priority server timestamp is NEWER → server wins
|
||||
await applyServerChanges('todo', [
|
||||
{
|
||||
table: 'tasks',
|
||||
id: 'task-C',
|
||||
op: 'update',
|
||||
fields: {
|
||||
title: { value: 'server title (loser)', updatedAt: '1970-01-01T00:00:00Z' },
|
||||
priority: { value: 'medium (winner)', updatedAt: '2099-01-01T00:00:00Z' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const stored = await db.table('tasks').get('task-C');
|
||||
expect(stored.title).toBe('local title v2'); // local field kept
|
||||
expect(stored.priority).toBe('medium (winner)'); // server field applied
|
||||
});
|
||||
|
||||
it('soft delete is applied when server timestamp is newer than local', async () => {
|
||||
await db.table('tasks').add({
|
||||
id: 'task-D',
|
||||
title: 'doomed',
|
||||
priority: 'low',
|
||||
isCompleted: false,
|
||||
order: 0,
|
||||
});
|
||||
|
||||
await applyServerChanges('todo', [
|
||||
{
|
||||
table: 'tasks',
|
||||
id: 'task-D',
|
||||
op: 'update',
|
||||
deletedAt: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
]);
|
||||
|
||||
const stored = await db.table('tasks').get('task-D');
|
||||
expect(stored).toBeDefined();
|
||||
expect(stored.deletedAt).toBe('2099-01-01T00:00:00Z');
|
||||
});
|
||||
|
||||
it('drops malformed entries but still applies the valid ones in the same batch', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
try {
|
||||
await applyServerChanges('todo', [
|
||||
// Malformed: missing id
|
||||
{ table: 'tasks', op: 'insert', data: { title: 'orphan' } },
|
||||
// Valid
|
||||
{
|
||||
table: 'tasks',
|
||||
id: 'task-E',
|
||||
op: 'insert',
|
||||
data: {
|
||||
id: 'task-E',
|
||||
title: 'survives',
|
||||
priority: 'low',
|
||||
isCompleted: false,
|
||||
order: 0,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(warn).toHaveBeenCalledOnce();
|
||||
const stored = await db.table('tasks').get('task-E');
|
||||
expect(stored).toBeDefined();
|
||||
expect(stored.title).toBe('survives');
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not generate _pendingChanges entries for server-applied writes (sync loop guard)', async () => {
|
||||
await applyServerChanges('todo', [
|
||||
{
|
||||
table: 'tasks',
|
||||
id: 'task-F',
|
||||
op: 'insert',
|
||||
data: {
|
||||
id: 'task-F',
|
||||
title: 'echo me not',
|
||||
priority: 'low',
|
||||
isCompleted: false,
|
||||
order: 0,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const pendingForTaskF = await db
|
||||
.table('_pendingChanges')
|
||||
.filter((p: { recordId?: string }) => p.recordId === 'task-F')
|
||||
.toArray();
|
||||
expect(pendingForTaskF).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -25,13 +25,40 @@ import {
|
|||
|
||||
// ─── Types ────────────────────────────────────────────────────
|
||||
|
||||
/** Operations the sync protocol supports. */
|
||||
export type SyncOp = 'insert' | 'update' | 'delete';
|
||||
|
||||
/** A single field-level change carrying its own LWW timestamp. */
|
||||
export interface FieldChange {
|
||||
value: unknown;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of a changeset on the wire. Pending changes (local) and server
|
||||
* changes (remote) share the same shape so the validator can be reused.
|
||||
*
|
||||
* Invariants the validator enforces:
|
||||
* - `op === 'update'` requires `fields` (record-level `data` is ignored).
|
||||
* - `op === 'insert'` requires `data`.
|
||||
* - A `deletedAt` flag implies a soft delete regardless of `op`.
|
||||
*/
|
||||
export interface SyncChange {
|
||||
table: string;
|
||||
id: string;
|
||||
op: SyncOp;
|
||||
fields?: Record<string, FieldChange>;
|
||||
data?: Record<string, unknown>;
|
||||
deletedAt?: string;
|
||||
}
|
||||
|
||||
interface PendingChange {
|
||||
id?: number;
|
||||
appId: string;
|
||||
collection: string;
|
||||
recordId: string;
|
||||
op: 'insert' | 'update' | 'delete';
|
||||
fields?: Record<string, { value: unknown; updatedAt: string }>;
|
||||
op: SyncOp;
|
||||
fields?: Record<string, FieldChange>;
|
||||
data?: Record<string, unknown>;
|
||||
deletedAt?: string;
|
||||
createdAt: string;
|
||||
|
|
@ -44,6 +71,223 @@ interface SyncMeta {
|
|||
pendingCount: number;
|
||||
}
|
||||
|
||||
// ─── Wire-format type guards ─────────────────────────────────
|
||||
//
|
||||
// Server payloads are untrusted: a malformed `serverChanges` entry must be
|
||||
// rejected before it touches Dexie. Hand-rolled guards keep us free of a
|
||||
// runtime-validation dependency while still narrowing types properly.
|
||||
|
||||
function isFieldChange(v: unknown): v is FieldChange {
|
||||
if (!v || typeof v !== 'object') return false;
|
||||
const f = v as Record<string, unknown>;
|
||||
return 'value' in f && (f.updatedAt === undefined || typeof f.updatedAt === 'string');
|
||||
}
|
||||
|
||||
function isFieldsMap(v: unknown): v is Record<string, FieldChange> {
|
||||
if (!v || typeof v !== 'object') return false;
|
||||
for (const value of Object.values(v as Record<string, unknown>)) {
|
||||
if (!isFieldChange(value)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isSyncOp(v: unknown): v is SyncOp {
|
||||
return v === 'insert' || v === 'update' || v === 'delete';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` only for objects that match the on-the-wire SyncChange
|
||||
* contract well enough to apply safely. Soft errors (missing optional
|
||||
* fields) are tolerated; structural errors (wrong types, missing id/table)
|
||||
* are not.
|
||||
*/
|
||||
export function isValidSyncChange(v: unknown): v is SyncChange {
|
||||
if (!v || typeof v !== 'object') return false;
|
||||
const c = v as Record<string, unknown>;
|
||||
if (typeof c.table !== 'string' || c.table === '') return false;
|
||||
if (typeof c.id !== 'string' || c.id === '') return false;
|
||||
if (!isSyncOp(c.op)) return false;
|
||||
if (c.fields !== undefined && !isFieldsMap(c.fields)) return false;
|
||||
if (c.data !== undefined && (typeof c.data !== 'object' || c.data === null)) return false;
|
||||
if (c.deletedAt !== undefined && typeof c.deletedAt !== 'string') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Apply Server Changes (top-level so unit tests can import directly) ──
|
||||
|
||||
/**
|
||||
* Reads the per-field LWW timestamps off a record. Returns an empty map for
|
||||
* legacy records that pre-date __fieldTimestamps so callers can fall back to
|
||||
* record-level `updatedAt`.
|
||||
*/
|
||||
export function readFieldTimestamps(record: unknown): Record<string, string> {
|
||||
if (!record || typeof record !== 'object') return {};
|
||||
const ft = (record as Record<string, unknown>)[FIELD_TIMESTAMPS_KEY];
|
||||
return ft && typeof ft === 'object' ? (ft as Record<string, string>) : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a batch of server changes to the local Dexie database with
|
||||
* field-level Last-Write-Wins conflict resolution.
|
||||
*
|
||||
* Three branches based on the change op:
|
||||
* - delete / deletedAt → soft delete (LWW-guarded) or hard delete
|
||||
* - insert → upsert with LWW merge against per-field timestamps
|
||||
* - update + fields → field-level LWW merge using server field timestamps
|
||||
*
|
||||
* Hooks are suppressed via setApplyingServerChanges so applied changes do
|
||||
* NOT generate new pending-changes (sync loop prevention). Malformed
|
||||
* entries are dropped before any DB work happens.
|
||||
*/
|
||||
export async function applyServerChanges(appId: string, changes: unknown[]): Promise<void> {
|
||||
// Reject malformed entries up-front so a single bad row from the server
|
||||
// can never write garbage into IndexedDB. Drops are logged once and the
|
||||
// good entries proceed — partial degradation beats a hard crash on a
|
||||
// payload we can't fix from the client.
|
||||
const validChanges: SyncChange[] = [];
|
||||
let dropped = 0;
|
||||
for (const c of changes) {
|
||||
if (isValidSyncChange(c)) validChanges.push(c);
|
||||
else dropped++;
|
||||
}
|
||||
if (dropped > 0) {
|
||||
console.warn(
|
||||
`[mana-sync] dropped ${dropped}/${changes.length} malformed server changes for app=${appId}`
|
||||
);
|
||||
}
|
||||
if (validChanges.length === 0) return;
|
||||
|
||||
setApplyingServerChanges(true);
|
||||
try {
|
||||
// Group changes by table (server returns backend collection names)
|
||||
const byTable = new Map<string, SyncChange[]>();
|
||||
for (const change of validChanges) {
|
||||
const unifiedTable = fromSyncName(appId, change.table);
|
||||
if (!byTable.has(unifiedTable)) byTable.set(unifiedTable, []);
|
||||
byTable.get(unifiedTable)!.push(change);
|
||||
}
|
||||
|
||||
for (const [tableName, tableChanges] of byTable) {
|
||||
const table = db.table(tableName);
|
||||
|
||||
await db.transaction('rw', table, async () => {
|
||||
for (const change of tableChanges) {
|
||||
const recordId = change.id;
|
||||
|
||||
if (change.deletedAt || change.op === 'delete') {
|
||||
const existing = await table.get(recordId);
|
||||
if (!existing) continue;
|
||||
if (change.deletedAt) {
|
||||
const localFT = readFieldTimestamps(existing);
|
||||
const serverTime = change.deletedAt;
|
||||
const localDeletedAtTime =
|
||||
localFT.deletedAt ??
|
||||
((existing as Record<string, unknown>).deletedAt as string | undefined) ??
|
||||
'';
|
||||
if (serverTime >= localDeletedAtTime) {
|
||||
await table.update(recordId, {
|
||||
deletedAt: serverTime,
|
||||
updatedAt: serverTime,
|
||||
[FIELD_TIMESTAMPS_KEY]: {
|
||||
...localFT,
|
||||
deletedAt: serverTime,
|
||||
updatedAt: serverTime,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await table.delete(recordId);
|
||||
}
|
||||
} else if (change.op === 'insert') {
|
||||
// Upsert. `change.data` is the canonical payload; fall back to
|
||||
// the change envelope only for older flattened formats.
|
||||
const existing = await table.get(recordId);
|
||||
const changeData = change.data ?? (change as unknown as Record<string, unknown>);
|
||||
const recordTime =
|
||||
(changeData.updatedAt as string | undefined) ??
|
||||
(changeData.createdAt as string | undefined) ??
|
||||
new Date().toISOString();
|
||||
|
||||
if (!existing) {
|
||||
const ft: Record<string, string> = {};
|
||||
for (const key of Object.keys(changeData)) {
|
||||
if (key === 'id' || key === FIELD_TIMESTAMPS_KEY) continue;
|
||||
ft[key] = recordTime;
|
||||
}
|
||||
await table.put({
|
||||
...changeData,
|
||||
id: recordId,
|
||||
[FIELD_TIMESTAMPS_KEY]: ft,
|
||||
});
|
||||
} else {
|
||||
const localFT = readFieldTimestamps(existing);
|
||||
const localUpdatedAt =
|
||||
((existing as Record<string, unknown>).updatedAt as string | undefined) ?? '';
|
||||
const updates: Record<string, unknown> = {};
|
||||
const newFT: Record<string, string> = { ...localFT };
|
||||
|
||||
for (const [key, val] of Object.entries(changeData)) {
|
||||
if (key === 'id' || key === FIELD_TIMESTAMPS_KEY) continue;
|
||||
const localFieldTime = localFT[key] ?? localUpdatedAt;
|
||||
if (recordTime >= localFieldTime) {
|
||||
updates[key] = val;
|
||||
newFT[key] = recordTime;
|
||||
}
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
updates[FIELD_TIMESTAMPS_KEY] = newFT;
|
||||
await table.update(recordId, updates);
|
||||
}
|
||||
}
|
||||
} else if (change.op === 'update' && change.fields) {
|
||||
// Field-level LWW update — the canonical conflict-resolution path.
|
||||
const existing = await table.get(recordId);
|
||||
const serverFields = change.fields;
|
||||
|
||||
if (!existing) {
|
||||
// Reconstruct from fields. Other clients only see this if the
|
||||
// record was deleted locally — recreate it under the server's
|
||||
// authority.
|
||||
const record: Record<string, unknown> = { id: recordId };
|
||||
const ft: Record<string, string> = {};
|
||||
const fallback = new Date().toISOString();
|
||||
for (const [key, fc] of Object.entries(serverFields)) {
|
||||
record[key] = fc.value;
|
||||
ft[key] = fc.updatedAt ?? fallback;
|
||||
}
|
||||
record[FIELD_TIMESTAMPS_KEY] = ft;
|
||||
await table.put(record);
|
||||
} else {
|
||||
// Per-field comparison. Falls back to record-level updatedAt
|
||||
// only for legacy records that pre-date __fieldTimestamps.
|
||||
const localFT = readFieldTimestamps(existing);
|
||||
const localUpdatedAt =
|
||||
((existing as Record<string, unknown>).updatedAt as string | undefined) ?? '';
|
||||
const updates: Record<string, unknown> = {};
|
||||
const newFT: Record<string, string> = { ...localFT };
|
||||
|
||||
for (const [key, fc] of Object.entries(serverFields)) {
|
||||
const serverTime = fc.updatedAt ?? '';
|
||||
const localFieldTime = localFT[key] ?? localUpdatedAt;
|
||||
if (serverTime >= localFieldTime) {
|
||||
updates[key] = fc.value;
|
||||
newFT[key] = serverTime;
|
||||
}
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
updates[FIELD_TIMESTAMPS_KEY] = newFT;
|
||||
await table.update(recordId, updates);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setApplyingServerChanges(false);
|
||||
}
|
||||
}
|
||||
|
||||
interface SyncChannelState {
|
||||
appId: string;
|
||||
tables: string[];
|
||||
|
|
@ -416,150 +660,6 @@ export function createUnifiedSync(serverUrl: string, getToken: () => Promise<str
|
|||
}
|
||||
}
|
||||
|
||||
// ─── Apply Server Changes ───────────────────────────────
|
||||
|
||||
function readFieldTimestamps(record: unknown): Record<string, string> {
|
||||
if (!record || typeof record !== 'object') return {};
|
||||
const ft = (record as Record<string, unknown>)[FIELD_TIMESTAMPS_KEY];
|
||||
return ft && typeof ft === 'object' ? (ft as Record<string, string>) : {};
|
||||
}
|
||||
|
||||
async function applyServerChanges(appId: string, changes: any[]): Promise<void> {
|
||||
setApplyingServerChanges(true);
|
||||
try {
|
||||
// Group changes by table (server returns backend collection names)
|
||||
const byTable = new Map<string, any[]>();
|
||||
for (const change of changes) {
|
||||
const serverTable = change.table;
|
||||
// Map backend collection name → unified table name
|
||||
const unifiedTable = fromSyncName(appId, serverTable);
|
||||
if (!byTable.has(unifiedTable)) byTable.set(unifiedTable, []);
|
||||
byTable.get(unifiedTable)!.push(change);
|
||||
}
|
||||
|
||||
for (const [tableName, tableChanges] of byTable) {
|
||||
const table = db.table(tableName);
|
||||
|
||||
await db.transaction('rw', table, async () => {
|
||||
for (const change of tableChanges) {
|
||||
const recordId = change.id;
|
||||
|
||||
if (change.deletedAt || change.op === 'delete') {
|
||||
// Soft delete (deletedAt) or hard delete
|
||||
const existing = await table.get(recordId);
|
||||
if (existing) {
|
||||
if (change.deletedAt) {
|
||||
const localFT = readFieldTimestamps(existing);
|
||||
const serverTime = change.deletedAt as string;
|
||||
// LWW guard: only apply if newer than local deletedAt timestamp
|
||||
const localDeletedAtTime = localFT.deletedAt ?? (existing as any).deletedAt ?? '';
|
||||
if (serverTime >= localDeletedAtTime) {
|
||||
const newFT = {
|
||||
...localFT,
|
||||
deletedAt: serverTime,
|
||||
updatedAt: serverTime,
|
||||
};
|
||||
await table.update(recordId, {
|
||||
deletedAt: serverTime,
|
||||
updatedAt: serverTime,
|
||||
[FIELD_TIMESTAMPS_KEY]: newFT,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await table.delete(recordId);
|
||||
}
|
||||
}
|
||||
} else if (change.op === 'insert') {
|
||||
// Upsert for inserts
|
||||
const existing = await table.get(recordId);
|
||||
const changeData = (change.data ?? change) as Record<string, unknown>;
|
||||
const recordTime =
|
||||
(changeData.updatedAt as string | undefined) ??
|
||||
(changeData.createdAt as string | undefined) ??
|
||||
new Date().toISOString();
|
||||
|
||||
if (!existing) {
|
||||
// Stamp every field at the record's timestamp
|
||||
const ft: Record<string, string> = {};
|
||||
for (const key of Object.keys(changeData)) {
|
||||
if (key === 'id' || key === FIELD_TIMESTAMPS_KEY) continue;
|
||||
ft[key] = recordTime;
|
||||
}
|
||||
await table.put({
|
||||
...changeData,
|
||||
id: recordId,
|
||||
[FIELD_TIMESTAMPS_KEY]: ft,
|
||||
});
|
||||
} else {
|
||||
// Existing record — merge with field-level LWW using recordTime as
|
||||
// the timestamp for every incoming field.
|
||||
const localFT = readFieldTimestamps(existing);
|
||||
const localUpdatedAt = (existing as any).updatedAt ?? '';
|
||||
const updates: Record<string, unknown> = {};
|
||||
const newFT: Record<string, string> = { ...localFT };
|
||||
|
||||
for (const [key, val] of Object.entries(changeData)) {
|
||||
if (key === 'id' || key === FIELD_TIMESTAMPS_KEY) continue;
|
||||
const localFieldTime = localFT[key] ?? localUpdatedAt;
|
||||
if (recordTime >= localFieldTime) {
|
||||
updates[key] = val;
|
||||
newFT[key] = recordTime;
|
||||
}
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
updates[FIELD_TIMESTAMPS_KEY] = newFT;
|
||||
await table.update(recordId, updates);
|
||||
}
|
||||
}
|
||||
} else if (change.op === 'update' && change.fields) {
|
||||
// Field-level LWW update
|
||||
const existing = await table.get(recordId);
|
||||
const serverFields = change.fields as Record<
|
||||
string,
|
||||
{ value: unknown; updatedAt?: string }
|
||||
>;
|
||||
|
||||
if (!existing) {
|
||||
// Record doesn't exist locally — reconstruct from fields
|
||||
const record: Record<string, unknown> = { id: recordId };
|
||||
const ft: Record<string, string> = {};
|
||||
const fallback = new Date().toISOString();
|
||||
for (const [key, fc] of Object.entries(serverFields)) {
|
||||
record[key] = fc.value;
|
||||
ft[key] = fc.updatedAt ?? fallback;
|
||||
}
|
||||
record[FIELD_TIMESTAMPS_KEY] = ft;
|
||||
await table.put(record);
|
||||
} else {
|
||||
// Merge — compare per-field timestamps. Falls back to record-level
|
||||
// updatedAt for legacy records that pre-date __fieldTimestamps.
|
||||
const localFT = readFieldTimestamps(existing);
|
||||
const localUpdatedAt = (existing as any).updatedAt ?? '';
|
||||
const updates: Record<string, unknown> = {};
|
||||
const newFT: Record<string, string> = { ...localFT };
|
||||
|
||||
for (const [key, fc] of Object.entries(serverFields)) {
|
||||
const serverTime = fc.updatedAt ?? '';
|
||||
const localFieldTime = localFT[key] ?? localUpdatedAt;
|
||||
if (serverTime >= localFieldTime) {
|
||||
updates[key] = fc.value;
|
||||
newFT[key] = serverTime;
|
||||
}
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
updates[FIELD_TIMESTAMPS_KEY] = newFT;
|
||||
await table.update(recordId, updates);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setApplyingServerChanges(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────
|
||||
|
||||
async function getSyncCursor(appId: string, collection: string): Promise<string> {
|
||||
|
|
|
|||
155
pnpm-lock.yaml
generated
155
pnpm-lock.yaml
generated
|
|
@ -1072,13 +1072,16 @@ importers:
|
|||
version: 1.1.0(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.44.0)(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.44.0)(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0)
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^4.0.14
|
||||
version: 4.0.14(vitest@4.1.2)
|
||||
version: 4.1.3(vitest@4.1.2)
|
||||
'@vitest/ui':
|
||||
specifier: ^4.0.14
|
||||
version: 4.0.14(vitest@4.1.2)
|
||||
version: 4.1.3(vitest@4.1.2)
|
||||
autoprefixer:
|
||||
specifier: ^10.4.20
|
||||
version: 10.4.22(postcss@8.5.8)
|
||||
fake-indexeddb:
|
||||
specifier: ^6.2.5
|
||||
version: 6.2.5
|
||||
postcss:
|
||||
specifier: ^8.4.49
|
||||
version: 8.5.8
|
||||
|
|
@ -1111,7 +1114,7 @@ importers:
|
|||
version: 6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||
vitest:
|
||||
specifier: ^4.0.14
|
||||
version: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(@vitest/ui@4.0.14)(jsdom@29.0.1(@noble/hashes@2.0.1))(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
version: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(@vitest/ui@4.1.3)(jsdom@29.0.1(@noble/hashes@2.0.1))(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
|
||||
apps/manavoxel:
|
||||
devDependencies:
|
||||
|
|
@ -1473,7 +1476,7 @@ importers:
|
|||
version: 1.2.0(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0)
|
||||
vitest:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(@vitest/ui@4.0.14)(jsdom@29.0.1(@noble/hashes@2.0.1))(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
version: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(@vitest/ui@4.1.3)(jsdom@29.0.1(@noble/hashes@2.0.1))(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
workbox-window:
|
||||
specifier: ^7.4.0
|
||||
version: 7.4.0
|
||||
|
|
@ -9418,11 +9421,11 @@ packages:
|
|||
webdriverio:
|
||||
optional: true
|
||||
|
||||
'@vitest/coverage-v8@4.0.14':
|
||||
resolution: {integrity: sha512-EYHLqN/BY6b47qHH7gtMxAg++saoGmsjWmAq9MlXxAz4M0NcHh9iOyKhBZyU4yxZqOd8Xnqp80/5saeitz4Cng==}
|
||||
'@vitest/coverage-v8@4.1.3':
|
||||
resolution: {integrity: sha512-/MBdrkA8t6hbdCWFKs09dPik774xvs4Z6L4bycdCxYNLHM8oZuRyosumQMG19LUlBsB6GeVpL1q4kFFazvyKGA==}
|
||||
peerDependencies:
|
||||
'@vitest/browser': 4.0.14
|
||||
vitest: 4.0.14
|
||||
'@vitest/browser': 4.1.3
|
||||
vitest: 4.1.3
|
||||
peerDependenciesMeta:
|
||||
'@vitest/browser':
|
||||
optional: true
|
||||
|
|
@ -9478,12 +9481,12 @@ packages:
|
|||
'@vitest/pretty-format@3.2.4':
|
||||
resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==}
|
||||
|
||||
'@vitest/pretty-format@4.0.14':
|
||||
resolution: {integrity: sha512-SOYPgujB6TITcJxgd3wmsLl+wZv+fy3av2PpiPpsWPZ6J1ySUYfScfpIt2Yv56ShJXR2MOA6q2KjKHN4EpdyRQ==}
|
||||
|
||||
'@vitest/pretty-format@4.1.2':
|
||||
resolution: {integrity: sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==}
|
||||
|
||||
'@vitest/pretty-format@4.1.3':
|
||||
resolution: {integrity: sha512-hYqqwuMbpkkBodpRh4k4cQSOELxXky1NfMmQvOfKvV8zQHz8x8Dla+2wzElkMkBvSAJX5TRGHJAQvK0TcOafwg==}
|
||||
|
||||
'@vitest/runner@1.6.1':
|
||||
resolution: {integrity: sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==}
|
||||
|
||||
|
|
@ -9525,10 +9528,10 @@ packages:
|
|||
peerDependencies:
|
||||
vitest: 3.2.4
|
||||
|
||||
'@vitest/ui@4.0.14':
|
||||
resolution: {integrity: sha512-fvDz8o7SQpFLoSBo6Cudv+fE85/fPCkwTnLAN85M+Jv7k59w2mSIjT9Q5px7XwGrmYqqKBEYxh/09IBGd1E7AQ==}
|
||||
'@vitest/ui@4.1.3':
|
||||
resolution: {integrity: sha512-xBPy+43o1fgMLUDlufUXh7tlT/Es8uS5eiyBY2PyPfFYSGpApZskLw65DROoDz+rgYkPuAmb20Mv9Z9g1WQE7w==}
|
||||
peerDependencies:
|
||||
vitest: 4.0.14
|
||||
vitest: 4.1.3
|
||||
|
||||
'@vitest/utils@1.6.1':
|
||||
resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==}
|
||||
|
|
@ -9539,12 +9542,12 @@ packages:
|
|||
'@vitest/utils@3.2.4':
|
||||
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
|
||||
|
||||
'@vitest/utils@4.0.14':
|
||||
resolution: {integrity: sha512-hLqXZKAWNg8pI+SQXyXxWCTOpA3MvsqcbVeNgSi8x/CSN2wi26dSzn1wrOhmCmFjEvN9p8/kLFRHa6PI8jHazw==}
|
||||
|
||||
'@vitest/utils@4.1.2':
|
||||
resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==}
|
||||
|
||||
'@vitest/utils@4.1.3':
|
||||
resolution: {integrity: sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw==}
|
||||
|
||||
'@volar/kit@2.4.28':
|
||||
resolution: {integrity: sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg==}
|
||||
peerDependencies:
|
||||
|
|
@ -9888,8 +9891,8 @@ packages:
|
|||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ast-v8-to-istanbul@0.3.8:
|
||||
resolution: {integrity: sha512-szgSZqUxI5T8mLKvS7WTjF9is+MVbOeLADU73IseOcrqhxr/VAvy6wfoVE39KnKzA7JRhjF5eUagNlHwvZPlKQ==}
|
||||
ast-v8-to-istanbul@1.0.0:
|
||||
resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==}
|
||||
|
||||
astring@1.9.0:
|
||||
resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==}
|
||||
|
|
@ -12718,6 +12721,10 @@ packages:
|
|||
engines: {node: '>= 10.17.0'}
|
||||
hasBin: true
|
||||
|
||||
fake-indexeddb@6.2.5:
|
||||
resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
|
|
@ -12869,8 +12876,8 @@ packages:
|
|||
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
flatted@3.3.3:
|
||||
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
|
||||
flatted@3.4.2:
|
||||
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
|
||||
|
||||
flattie@1.1.1:
|
||||
resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==}
|
||||
|
|
@ -14053,6 +14060,9 @@ packages:
|
|||
js-image-generator@1.0.4:
|
||||
resolution: {integrity: sha512-ckb7kyVojGAnArouVR+5lBIuwU1fcrn7E/YYSd0FK7oIngAkMmRvHASLro9Zt5SQdWToaI66NybG+OGxPw/HlQ==}
|
||||
|
||||
js-tokens@10.0.0:
|
||||
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
|
|
@ -14525,6 +14535,9 @@ packages:
|
|||
magicast@0.5.1:
|
||||
resolution: {integrity: sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==}
|
||||
|
||||
magicast@0.5.2:
|
||||
resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==}
|
||||
|
||||
make-dir@4.0.0:
|
||||
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -28103,22 +28116,19 @@ snapshots:
|
|||
- vite
|
||||
optional: true
|
||||
|
||||
'@vitest/coverage-v8@4.0.14(vitest@4.1.2)':
|
||||
'@vitest/coverage-v8@4.1.3(vitest@4.1.2)':
|
||||
dependencies:
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
'@vitest/utils': 4.0.14
|
||||
ast-v8-to-istanbul: 0.3.8
|
||||
'@vitest/utils': 4.1.3
|
||||
ast-v8-to-istanbul: 1.0.0
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
istanbul-lib-source-maps: 5.0.6
|
||||
istanbul-reports: 3.2.0
|
||||
magicast: 0.5.1
|
||||
magicast: 0.5.2
|
||||
obug: 2.1.1
|
||||
std-env: 3.10.0
|
||||
std-env: 4.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(@vitest/ui@4.0.14)(jsdom@29.0.1(@noble/hashes@2.0.1))(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
vitest: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(@vitest/ui@4.1.3)(jsdom@29.0.1(@noble/hashes@2.0.1))(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
|
||||
'@vitest/expect@1.6.1':
|
||||
dependencies:
|
||||
|
|
@ -28223,11 +28233,11 @@ snapshots:
|
|||
dependencies:
|
||||
tinyrainbow: 2.0.0
|
||||
|
||||
'@vitest/pretty-format@4.0.14':
|
||||
'@vitest/pretty-format@4.1.2':
|
||||
dependencies:
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/pretty-format@4.1.2':
|
||||
'@vitest/pretty-format@4.1.3':
|
||||
dependencies:
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
|
|
@ -28296,7 +28306,7 @@ snapshots:
|
|||
dependencies:
|
||||
'@vitest/utils': 3.2.4
|
||||
fflate: 0.8.2
|
||||
flatted: 3.3.3
|
||||
flatted: 3.4.2
|
||||
pathe: 2.0.3
|
||||
sirv: 3.0.2
|
||||
tinyglobby: 0.2.15
|
||||
|
|
@ -28304,16 +28314,16 @@ snapshots:
|
|||
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@29.0.1(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||
optional: true
|
||||
|
||||
'@vitest/ui@4.0.14(vitest@4.1.2)':
|
||||
'@vitest/ui@4.1.3(vitest@4.1.2)':
|
||||
dependencies:
|
||||
'@vitest/utils': 4.0.14
|
||||
'@vitest/utils': 4.1.3
|
||||
fflate: 0.8.2
|
||||
flatted: 3.3.3
|
||||
flatted: 3.4.2
|
||||
pathe: 2.0.3
|
||||
sirv: 3.0.2
|
||||
tinyglobby: 0.2.15
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(@vitest/ui@4.0.14)(jsdom@29.0.1(@noble/hashes@2.0.1))(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
vitest: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(@vitest/ui@4.1.3)(jsdom@29.0.1(@noble/hashes@2.0.1))(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
|
||||
'@vitest/utils@1.6.1':
|
||||
dependencies:
|
||||
|
|
@ -28334,17 +28344,18 @@ snapshots:
|
|||
loupe: 3.2.1
|
||||
tinyrainbow: 2.0.0
|
||||
|
||||
'@vitest/utils@4.0.14':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.0.14
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/utils@4.1.2':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.2
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/utils@4.1.3':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.3
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@volar/kit@2.4.28(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@volar/language-service': 2.4.28
|
||||
|
|
@ -28753,11 +28764,11 @@ snapshots:
|
|||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
ast-v8-to-istanbul@0.3.8:
|
||||
ast-v8-to-istanbul@1.0.0:
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
estree-walker: 3.0.3
|
||||
js-tokens: 9.0.1
|
||||
js-tokens: 10.0.0
|
||||
|
||||
astring@1.9.0: {}
|
||||
|
||||
|
|
@ -31137,7 +31148,7 @@ snapshots:
|
|||
'@typescript-eslint/eslint-plugin': 8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint: 9.39.1(jiti@2.6.1)
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1))
|
||||
eslint-plugin-expo: 1.0.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1))
|
||||
eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1))
|
||||
|
|
@ -31154,7 +31165,7 @@ snapshots:
|
|||
'@typescript-eslint/eslint-plugin': 8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint: 9.39.1(jiti@2.6.1)
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1))
|
||||
eslint-plugin-expo: 1.0.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1))
|
||||
eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1))
|
||||
|
|
@ -31171,7 +31182,7 @@ snapshots:
|
|||
'@typescript-eslint/eslint-plugin': 8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3)
|
||||
'@typescript-eslint/parser': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3)
|
||||
eslint: 9.39.1(jiti@2.6.1)
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1))
|
||||
eslint-plugin-expo: 0.1.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3)
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1))
|
||||
eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1))
|
||||
|
|
@ -31315,22 +31326,7 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@nolyfill/is-core-module': 1.0.39
|
||||
debug: 4.4.3
|
||||
eslint: 9.39.1(jiti@2.6.1)
|
||||
get-tsconfig: 4.13.0
|
||||
is-bun-module: 2.0.0
|
||||
stable-hash: 0.0.5
|
||||
tinyglobby: 0.2.15
|
||||
unrs-resolver: 1.11.1
|
||||
optionalDependencies:
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)):
|
||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@nolyfill/is-core-module': 1.0.39
|
||||
debug: 4.4.3
|
||||
|
|
@ -31375,25 +31371,25 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)):
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)):
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3)
|
||||
eslint: 9.39.1(jiti@2.6.1)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)):
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)):
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint: 9.39.1(jiti@2.6.1)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
|
@ -31560,7 +31556,7 @@ snapshots:
|
|||
doctrine: 2.1.0
|
||||
eslint: 9.39.1(jiti@2.6.1)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1))
|
||||
hasown: 2.0.2
|
||||
is-core-module: 2.16.1
|
||||
is-glob: 4.0.3
|
||||
|
|
@ -31589,7 +31585,7 @@ snapshots:
|
|||
doctrine: 2.1.0
|
||||
eslint: 9.39.1(jiti@2.6.1)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1))
|
||||
hasown: 2.0.2
|
||||
is-core-module: 2.16.1
|
||||
is-glob: 4.0.3
|
||||
|
|
@ -33635,6 +33631,8 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
fake-indexeddb@6.2.5: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-diff@1.3.0: {}
|
||||
|
|
@ -33816,16 +33814,16 @@ snapshots:
|
|||
|
||||
flat-cache@3.2.0:
|
||||
dependencies:
|
||||
flatted: 3.3.3
|
||||
flatted: 3.4.2
|
||||
keyv: 4.5.4
|
||||
rimraf: 3.0.2
|
||||
|
||||
flat-cache@4.0.1:
|
||||
dependencies:
|
||||
flatted: 3.3.3
|
||||
flatted: 3.4.2
|
||||
keyv: 4.5.4
|
||||
|
||||
flatted@3.3.3: {}
|
||||
flatted@3.4.2: {}
|
||||
|
||||
flattie@1.1.1: {}
|
||||
|
||||
|
|
@ -34900,6 +34898,7 @@ snapshots:
|
|||
istanbul-lib-coverage: 3.2.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
istanbul-reports@3.2.0:
|
||||
dependencies:
|
||||
|
|
@ -36044,6 +36043,8 @@ snapshots:
|
|||
jpeg-js: 0.4.4
|
||||
optional: true
|
||||
|
||||
js-tokens@10.0.0: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-tokens@9.0.1: {}
|
||||
|
|
@ -36490,6 +36491,12 @@ snapshots:
|
|||
'@babel/types': 7.29.0
|
||||
source-map-js: 1.2.1
|
||||
|
||||
magicast@0.5.2:
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.2
|
||||
'@babel/types': 7.29.0
|
||||
source-map-js: 1.2.1
|
||||
|
||||
make-dir@4.0.0:
|
||||
dependencies:
|
||||
semver: 7.7.3
|
||||
|
|
@ -42180,7 +42187,7 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
vitest@4.1.2(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(@vitest/ui@4.0.14)(jsdom@29.0.1(@noble/hashes@2.0.1))(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)):
|
||||
vitest@4.1.2(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(@vitest/ui@4.1.3)(jsdom@29.0.1(@noble/hashes@2.0.1))(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.2
|
||||
'@vitest/mocker': 4.1.2(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
|
|
@ -42205,7 +42212,7 @@ snapshots:
|
|||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@types/node': 22.19.1
|
||||
'@vitest/ui': 4.0.14(vitest@4.1.2)
|
||||
'@vitest/ui': 4.1.3(vitest@4.1.2)
|
||||
jsdom: 29.0.1(@noble/hashes@2.0.1)
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue