@abloatai/ablo 0.9.2 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +47 -27
  4. package/dist/BaseSyncedStore.d.ts +7 -38
  5. package/dist/BaseSyncedStore.js +20 -67
  6. package/dist/Database.js +7 -1
  7. package/dist/NetworkMonitor.js +4 -1
  8. package/dist/SyncClient.d.ts +18 -5
  9. package/dist/SyncClient.js +72 -1
  10. package/dist/SyncEngineContext.js +5 -1
  11. package/dist/auth/index.js +3 -1
  12. package/dist/cli.cjs +282241 -0
  13. package/dist/client/Ablo.d.ts +12 -3
  14. package/dist/client/Ablo.js +36 -3
  15. package/dist/client/ApiClient.js +39 -6
  16. package/dist/client/auth.d.ts +1 -1
  17. package/dist/client/auth.js +14 -5
  18. package/dist/client/createInternalComponents.js +1 -1
  19. package/dist/client/createModelProxy.d.ts +9 -0
  20. package/dist/client/createModelProxy.js +34 -10
  21. package/dist/client/persistence.d.ts +6 -1
  22. package/dist/client/persistence.js +1 -1
  23. package/dist/client/registerDataSource.d.ts +4 -4
  24. package/dist/client/registerDataSource.js +39 -31
  25. package/dist/client/writeOptionsSchema.d.ts +50 -0
  26. package/dist/client/writeOptionsSchema.js +57 -0
  27. package/dist/core/index.d.ts +18 -26
  28. package/dist/core/index.js +22 -46
  29. package/dist/errorCodes.d.ts +13 -0
  30. package/dist/errorCodes.js +16 -1
  31. package/dist/index.d.ts +3 -0
  32. package/dist/index.js +7 -0
  33. package/dist/interfaces/index.d.ts +10 -0
  34. package/dist/mutators/UndoManager.d.ts +31 -5
  35. package/dist/mutators/UndoManager.js +113 -1
  36. package/dist/schema/ddl.js +12 -3
  37. package/dist/schema/field.js +2 -1
  38. package/dist/schema/model.d.ts +9 -7
  39. package/dist/schema/model.js +1 -1
  40. package/dist/schema/schema.js +7 -1
  41. package/dist/schema/serialize.js +2 -1
  42. package/dist/server/storage-mode.d.ts +7 -0
  43. package/dist/server/storage-mode.js +6 -0
  44. package/dist/source/adapters/drizzle.js +3 -2
  45. package/dist/source/adapters/kysely.d.ts +68 -0
  46. package/dist/source/adapters/kysely.js +210 -0
  47. package/dist/source/adapters/memory.js +2 -1
  48. package/dist/source/adapters/prisma.js +3 -2
  49. package/dist/source/index.js +2 -1
  50. package/dist/sync/syncPosition.d.ts +78 -0
  51. package/dist/sync/syncPosition.js +111 -0
  52. package/dist/transactions/TransactionQueue.d.ts +22 -8
  53. package/dist/transactions/TransactionQueue.js +76 -34
  54. package/dist/utils/duration.js +3 -2
  55. package/docs/api-keys.md +4 -4
  56. package/docs/cli.md +6 -6
  57. package/docs/client-behavior.md +1 -1
  58. package/docs/data-sources.md +61 -42
  59. package/docs/guarantees.md +2 -2
  60. package/docs/index.md +2 -2
  61. package/docs/integration-guide.md +4 -7
  62. package/docs/mcp.md +1 -1
  63. package/docs/quickstart.md +84 -37
  64. package/docs/schema-contract.md +2 -4
  65. package/llms-full.txt +365 -0
  66. package/llms.txt +14 -9
  67. package/package.json +26 -4
@@ -7,6 +7,12 @@
7
7
  * - `hosted` — Ablo's control-plane database.
8
8
  * - `selfHosted` — the customer's database, same execution path as hosted.
9
9
  * - `source` — a customer-owned endpoint (credentialless ingestion).
10
+ *
11
+ * @internal Deployment topology, not product vocabulary. Customers never see a
12
+ * "storage mode" — their story is `Ablo({ schema, apiKey, databaseUrl })` and
13
+ * one `datasource` resource (docs/plans/sync-engine-stripe-story-scope.md).
14
+ * This export exists for the sync-server host only.
10
15
  */
11
16
  import { z } from 'zod';
17
+ /** @internal See module note — host-deployment vocabulary, never customer-facing. */
12
18
  export const storageModeSchema = z.enum(['hosted', 'source', 'selfHosted']);
@@ -28,6 +28,7 @@
28
28
  * We use `sql` + `db.execute` for ALL writes (not the fluent builder) so the
29
29
  * adapter is one small, fully-typed unit with no per-driver builder generics.
30
30
  */
31
+ import { AbloValidationError } from '../../errors.js';
31
32
  import { sql } from 'drizzle-orm';
32
33
  import { outboxEventSchema } from '../contract.js';
33
34
  import { adapterTableMigrations } from '../migrations.js';
@@ -40,7 +41,7 @@ function rowsOf(result) {
40
41
  function rowId(op) {
41
42
  const id = op.id ?? op.input?.id;
42
43
  if (typeof id !== 'string' || id.length === 0) {
43
- throw new Error(`operation on "${op.model}" requires an id`);
44
+ throw new AbloValidationError(`operation on "${op.model}" requires an id`, { code: 'source_operation_id_required' });
44
45
  }
45
46
  return id;
46
47
  }
@@ -76,7 +77,7 @@ export function drizzleDataSource(db, schema) {
76
77
  const modelColumns = (model) => {
77
78
  const mc = maps.get(model);
78
79
  if (!mc)
79
- throw new Error(`drizzleDataSource: no model "${model}" in schema`);
80
+ throw new AbloValidationError(`drizzleDataSource: no model "${model}" in schema`, { code: 'source_adapter_misconfigured' });
80
81
  return mc;
81
82
  };
82
83
  const columnFor = (mc, field) => mc.fieldToColumn.get(field) ?? camelToSnake(field);
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Kysely Data Source adapter. Same adapter interface + conformance shape as
3
+ * `prismaDataSource` / `drizzleDataSource`, built against Kysely's REAL
4
+ * query-builder API:
5
+ * - `db.transaction().execute(async (trx) => …)` — interactive transaction.
6
+ * - `insertInto/updateTable/deleteFrom/selectFrom` + `returningAll()` —
7
+ * the fluent builder; table/column names are plain strings, so no raw
8
+ * SQL tag is needed and this module imports NOTHING from `kysely`
9
+ * (structural `KyselyLike`, mirroring the Prisma adapter's zero-dep
10
+ * `PrismaLike`).
11
+ *
12
+ * SCHEMA-DRIVEN COLUMNS. Kysely is SQL-near: it passes the column names you
13
+ * give it through verbatim (no Prisma-style `@map`). Like the Drizzle
14
+ * adapter, every table + column name is derived from the SAME rule the
15
+ * provisioner uses (`generateProvisionPlan`):
16
+ * table = `model.tableName ?? key`
17
+ * column = `fieldMeta.column ?? camelToSnake(field)` (+ the tenancy column)
18
+ * so `ablo migrate` (which emits `operator_id`) and this adapter COMPOSE.
19
+ * The adapter is the translation boundary: rows in/out are field-keyed (the
20
+ * SDK shape); the physical columns it reads/writes are snake_case.
21
+ *
22
+ * JSONB note: the outbox `data` / idempotency `response` values are passed
23
+ * as JSON strings — Postgres infers the parameter type from the target
24
+ * `jsonb` column, so the coercion is server-side and driver-agnostic (no
25
+ * `::jsonb` cast available without raw SQL).
26
+ */
27
+ import type { DataSourceAdapter, Row } from '../adapter.js';
28
+ import type { Schema, SchemaRecord } from '../../schema/schema.js';
29
+ /**
30
+ * The subset of a Kysely instance (or transaction handle) the adapter calls.
31
+ * Structural on purpose — declared with method shorthand so a real
32
+ * `Kysely<DB>` (whose params are narrowed to `keyof DB`) stays assignable
33
+ * under TypeScript's method bivariance, exactly like `PrismaLike`.
34
+ */
35
+ export interface KyselyLike {
36
+ selectFrom(table: string): KyselySelectBuilder;
37
+ insertInto(table: string): KyselyInsertBuilder;
38
+ updateTable(table: string): KyselyUpdateBuilder;
39
+ deleteFrom(table: string): KyselyDeleteBuilder;
40
+ transaction(): KyselyTransactionBuilder;
41
+ }
42
+ export interface KyselyTransactionBuilder {
43
+ execute<T>(fn: (trx: KyselyLike) => Promise<T>): Promise<T>;
44
+ }
45
+ export interface KyselySelectBuilder {
46
+ selectAll(): KyselySelectBuilder;
47
+ where(column: string, operator: string, value: unknown): KyselySelectBuilder;
48
+ orderBy(column: string, direction: 'asc' | 'desc'): KyselySelectBuilder;
49
+ limit(limit: number): KyselySelectBuilder;
50
+ execute(): Promise<readonly Row[]>;
51
+ }
52
+ export interface KyselyInsertBuilder {
53
+ values(row: Row): KyselyInsertBuilder;
54
+ returningAll(): KyselyInsertBuilder;
55
+ execute(): Promise<readonly Row[]>;
56
+ }
57
+ export interface KyselyUpdateBuilder {
58
+ set(patch: Row): KyselyUpdateBuilder;
59
+ where(column: string, operator: string, value: unknown): KyselyUpdateBuilder;
60
+ returningAll(): KyselyUpdateBuilder;
61
+ execute(): Promise<readonly Row[]>;
62
+ }
63
+ export interface KyselyDeleteBuilder {
64
+ where(column: string, operator: string, value: unknown): KyselyDeleteBuilder;
65
+ returningAll(): KyselyDeleteBuilder;
66
+ execute(): Promise<readonly Row[]>;
67
+ }
68
+ export declare function kyselyDataSource<S extends SchemaRecord>(db: KyselyLike, schema: Schema<S>): DataSourceAdapter;
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Kysely Data Source adapter. Same adapter interface + conformance shape as
3
+ * `prismaDataSource` / `drizzleDataSource`, built against Kysely's REAL
4
+ * query-builder API:
5
+ * - `db.transaction().execute(async (trx) => …)` — interactive transaction.
6
+ * - `insertInto/updateTable/deleteFrom/selectFrom` + `returningAll()` —
7
+ * the fluent builder; table/column names are plain strings, so no raw
8
+ * SQL tag is needed and this module imports NOTHING from `kysely`
9
+ * (structural `KyselyLike`, mirroring the Prisma adapter's zero-dep
10
+ * `PrismaLike`).
11
+ *
12
+ * SCHEMA-DRIVEN COLUMNS. Kysely is SQL-near: it passes the column names you
13
+ * give it through verbatim (no Prisma-style `@map`). Like the Drizzle
14
+ * adapter, every table + column name is derived from the SAME rule the
15
+ * provisioner uses (`generateProvisionPlan`):
16
+ * table = `model.tableName ?? key`
17
+ * column = `fieldMeta.column ?? camelToSnake(field)` (+ the tenancy column)
18
+ * so `ablo migrate` (which emits `operator_id`) and this adapter COMPOSE.
19
+ * The adapter is the translation boundary: rows in/out are field-keyed (the
20
+ * SDK shape); the physical columns it reads/writes are snake_case.
21
+ *
22
+ * JSONB note: the outbox `data` / idempotency `response` values are passed
23
+ * as JSON strings — Postgres infers the parameter type from the target
24
+ * `jsonb` column, so the coercion is server-side and driver-agnostic (no
25
+ * `::jsonb` cast available without raw SQL).
26
+ */
27
+ import { AbloValidationError } from '../../errors.js';
28
+ import { outboxEventSchema } from '../contract.js';
29
+ import { adapterTableMigrations } from '../migrations.js';
30
+ import { toSchemaJSON } from '../../schema/serialize.js';
31
+ import { camelToSnake, snakeToCamel } from '../../schema/ddl.js';
32
+ import { tenancyColumn } from '../../schema/tenancy.js';
33
+ function rowId(op) {
34
+ const id = op.id ?? op.input?.id;
35
+ if (typeof id !== 'string' || id.length === 0) {
36
+ throw new AbloValidationError(`operation on "${op.model}" requires an id`, {
37
+ code: 'source_operation_id_required',
38
+ });
39
+ }
40
+ return id;
41
+ }
42
+ function buildColumnMaps(schema) {
43
+ const json = toSchemaJSON(schema);
44
+ const out = new Map();
45
+ for (const [key, model] of Object.entries(json.models)) {
46
+ const fieldToColumn = new Map();
47
+ const columnToField = new Map();
48
+ const register = (field, column) => {
49
+ if (column === camelToSnake(field))
50
+ return;
51
+ fieldToColumn.set(field, column);
52
+ columnToField.set(column, field);
53
+ };
54
+ for (const [field, meta] of Object.entries(model.fields)) {
55
+ if (meta.column)
56
+ register(field, meta.column);
57
+ }
58
+ const orgColumn = tenancyColumn(model.tenancy);
59
+ if (orgColumn)
60
+ register('organizationId', orgColumn);
61
+ out.set(key, { table: model.tableName ?? key, fieldToColumn, columnToField });
62
+ }
63
+ return out;
64
+ }
65
+ export function kyselyDataSource(db, schema) {
66
+ const maps = buildColumnMaps(schema);
67
+ const modelColumns = (model) => {
68
+ const mc = maps.get(model);
69
+ if (!mc) {
70
+ throw new AbloValidationError(`kyselyDataSource: no model "${model}" in schema`, {
71
+ code: 'source_adapter_misconfigured',
72
+ });
73
+ }
74
+ return mc;
75
+ };
76
+ const columnFor = (mc, field) => mc.fieldToColumn.get(field) ?? camelToSnake(field);
77
+ const fieldFor = (mc, column) => mc.columnToField.get(column) ?? snakeToCamel(column);
78
+ /** Field-keyed (SDK shape) → column-keyed (physical), for INSERT/UPDATE. */
79
+ const toColumns = (mc, row) => {
80
+ const out = {};
81
+ for (const k of Object.keys(row))
82
+ out[columnFor(mc, k)] = row[k];
83
+ return out;
84
+ };
85
+ /** Column-keyed (RETURNING * / SELECT *) → field-keyed (SDK shape). */
86
+ const toFields = (mc, row) => {
87
+ const out = {};
88
+ for (const k of Object.keys(row))
89
+ out[fieldFor(mc, k)] = row[k];
90
+ return out;
91
+ };
92
+ const applyOperation = async (trx, op) => {
93
+ const mc = modelColumns(op.model);
94
+ const id = rowId(op);
95
+ const input = op.input ?? {};
96
+ if (op.type === 'DELETE') {
97
+ const deleted = await trx
98
+ .deleteFrom(mc.table)
99
+ .where('id', '=', id)
100
+ .returningAll()
101
+ .execute();
102
+ return deleted[0] ? toFields(mc, deleted[0]) : { id };
103
+ }
104
+ if (op.type === 'CREATE') {
105
+ const inserted = await trx
106
+ .insertInto(mc.table)
107
+ .values(toColumns(mc, { id, ...input }))
108
+ .returningAll()
109
+ .execute();
110
+ return inserted[0] ? toFields(mc, inserted[0]) : { id, ...input };
111
+ }
112
+ // UPDATE / ARCHIVE / UNARCHIVE — the lifecycle field is `archivedAt`
113
+ // (camelCase) and goes through `toColumns` like any other, so it lands in
114
+ // `archived_at` — the same column the provisioner emits.
115
+ const patch = toColumns(mc, {
116
+ ...input,
117
+ ...(op.type === 'ARCHIVE' ? { archivedAt: new Date() } : {}),
118
+ ...(op.type === 'UNARCHIVE' ? { archivedAt: null } : {}),
119
+ });
120
+ const updated = await trx
121
+ .updateTable(mc.table)
122
+ .set(patch)
123
+ .where('id', '=', id)
124
+ .returningAll()
125
+ .execute();
126
+ return updated[0] ? toFields(mc, updated[0]) : { id, ...input };
127
+ };
128
+ return {
129
+ capabilities: { transactions: true, propose: false, schemaIntrospection: true },
130
+ migrations() {
131
+ return adapterTableMigrations();
132
+ },
133
+ async read(req) {
134
+ const mc = modelColumns(req.model);
135
+ if (req.kind === 'load') {
136
+ const rows = await db
137
+ .selectFrom(mc.table)
138
+ .selectAll()
139
+ .where('id', '=', req.id)
140
+ .limit(1)
141
+ .execute();
142
+ return rows.map((r) => toFields(mc, r));
143
+ }
144
+ const limit = req.query?.limit ?? 1000;
145
+ const rows = await db.selectFrom(mc.table).selectAll().limit(limit).execute();
146
+ return rows.map((r) => toFields(mc, r));
147
+ },
148
+ async commit(change) {
149
+ return db.transaction().execute(async (trx) => {
150
+ const cached = await trx
151
+ .selectFrom('ablo_idempotency')
152
+ .selectAll()
153
+ .where('client_tx_id', '=', change.clientTxId)
154
+ .limit(1)
155
+ .execute();
156
+ if (cached.length > 0) {
157
+ const response = cached[0].response;
158
+ return {
159
+ rows: (typeof response === 'string' ? JSON.parse(response) : response),
160
+ };
161
+ }
162
+ const rows = [];
163
+ for (const [index, op] of change.operations.entries()) {
164
+ const row = await applyOperation(trx, op);
165
+ rows.push(row);
166
+ const entityId = String(row.id ?? rowId(op));
167
+ await trx
168
+ .insertInto('ablo_outbox')
169
+ .values({
170
+ id: `${change.clientTxId}:${index}`,
171
+ model: op.model,
172
+ entity_id: entityId,
173
+ type: op.type,
174
+ data: op.type === 'DELETE' ? null : JSON.stringify(row),
175
+ client_tx_id: change.clientTxId,
176
+ occurred_at: Date.now(),
177
+ })
178
+ .execute();
179
+ }
180
+ await trx
181
+ .insertInto('ablo_idempotency')
182
+ .values({ client_tx_id: change.clientTxId, response: JSON.stringify(rows) })
183
+ .execute();
184
+ return { rows };
185
+ });
186
+ },
187
+ async events(cursor, limit) {
188
+ const after = cursor ?? '0';
189
+ const rows = await db
190
+ .selectFrom('ablo_outbox')
191
+ .selectAll()
192
+ .where('cursor', '>', after)
193
+ .orderBy('cursor', 'asc')
194
+ .limit(limit)
195
+ .execute();
196
+ const events = rows.map((r) => outboxEventSchema.parse({
197
+ id: r.id,
198
+ model: r.model,
199
+ entityId: r.entity_id,
200
+ type: r.type,
201
+ data: typeof r.data === 'string' ? JSON.parse(r.data) : r.data ?? null,
202
+ organizationId: r.organization_id ?? null,
203
+ clientTxId: r.client_tx_id ?? null,
204
+ occurredAt: r.occurred_at != null ? Number(r.occurred_at) : null,
205
+ cursor: String(r.cursor),
206
+ }));
207
+ return { events, nextCursor: events.length > 0 ? events[events.length - 1].cursor : null };
208
+ },
209
+ };
210
+ }
@@ -8,10 +8,11 @@
8
8
  * It models the real semantics minimally but faithfully: one canonical row store
9
9
  * per model, an idempotency ledger keyed by `clientTxId`, and a monotonic outbox.
10
10
  */
11
+ import { AbloValidationError } from '../../errors.js';
11
12
  function rowId(op) {
12
13
  const id = op.id ?? op.input?.id;
13
14
  if (typeof id !== 'string' || id.length === 0) {
14
- throw new Error(`operation on "${op.model}" requires an id`);
15
+ throw new AbloValidationError(`operation on "${op.model}" requires an id`, { code: 'source_operation_id_required' });
15
16
  }
16
17
  return id;
17
18
  }
@@ -12,6 +12,7 @@
12
12
  * (`PrismaLike`), so this compiles in the SDK package and is unit-testable with
13
13
  * a fake, while a real `PrismaClient` satisfies it at the call site.
14
14
  */
15
+ import { AbloValidationError } from '../../errors.js';
15
16
  import { outboxEventSchema } from '../contract.js';
16
17
  import { adapterTableMigrations } from '../migrations.js';
17
18
  const lowerFirst = (s) => (s ? s[0].toLowerCase() + s.slice(1) : s);
@@ -32,7 +33,7 @@ const lowerFirst = (s) => (s ? s[0].toLowerCase() + s.slice(1) : s);
32
33
  function delegateFor(client, name) {
33
34
  const delegate = client[name];
34
35
  if (!delegate || typeof delegate.findMany !== 'function') {
35
- throw new Error(`prismaDataSource: no Prisma delegate "${name}" on the client`);
36
+ throw new AbloValidationError(`prismaDataSource: no Prisma delegate "${name}" on the client`, { code: 'source_adapter_misconfigured' });
36
37
  }
37
38
  return delegate;
38
39
  }
@@ -97,7 +98,7 @@ function findManyArgs(query) {
97
98
  function rowId(op) {
98
99
  const id = op.id ?? op.input?.id;
99
100
  if (typeof id !== 'string' || id.length === 0) {
100
- throw new Error(`operation on "${op.model}" requires an id`);
101
+ throw new AbloValidationError(`operation on "${op.model}" requires an id`, { code: 'source_operation_id_required' });
101
102
  }
102
103
  return id;
103
104
  }
@@ -1,3 +1,4 @@
1
+ import { AbloValidationError } from '../errors.js';
1
2
  import { changeSetSchema } from './contract.js';
2
3
  /**
3
4
  * Build the source-event marker customers should write to their outbox table in
@@ -10,7 +11,7 @@ import { changeSetSchema } from './contract.js';
10
11
  export function sourceEventForOperation(options) {
11
12
  const entityId = options.entityId ?? options.operation.id;
12
13
  if (typeof entityId !== 'string' || entityId.length === 0) {
13
- throw new Error('sourceEventForOperation requires operation.id or an explicit entityId');
14
+ throw new AbloValidationError('sourceEventForOperation requires operation.id or an explicit entityId', { code: 'source_event_invalid' });
14
15
  }
15
16
  const occurredAt = normalizeEventOccurredAt(options.occurredAt);
16
17
  return {
@@ -0,0 +1,78 @@
1
+ /**
2
+ * THE sync-position structure — one typed object for "where is this client
3
+ * in the global delta order", replacing five scattered private counters
4
+ * (`lastSeenSyncId` on the queue, `highestProcessedSyncId` + `lastAckedId`
5
+ * on the store, ad-hoc acked watermarks, `max()` calls at snapshot sites).
6
+ *
7
+ * Three facts with DIFFERENT advance disciplines — flattening them was the
8
+ * historical bug source, so the structure models them explicitly:
9
+ *
10
+ * - `persisted` — the resume/ack cursor. Advances ONLY after deltas have
11
+ * committed to IndexedDB (the Replicache "lastMutationID read in the
12
+ * same transaction as the client view" rule — see SyncWebSocket.sendAck).
13
+ * This is what reconnect catch-up sends; it must never run ahead of
14
+ * durable state or the server skips deltas that never landed.
15
+ *
16
+ * - `applied` — the in-memory cursor: the last delta APPLIED to the
17
+ * object pool. Drives delta dedup/replay guards. May run ahead of
18
+ * `persisted` (pool applies before the IDB flush) and behind receipt
19
+ * (bootstrap-queued deltas are received but not yet applied).
20
+ *
21
+ * - `acked` — the highest server watermark ACKED to this client's OWN
22
+ * commits. An ack at N means the server applied our write at N; the
23
+ * optimistic pool already reflects it, so for entities we wrote we have
24
+ * logically read through N even before the stream echo arrives.
25
+ *
26
+ * One derived read: `readFloor` = max(applied, acked) — the ONLY value
27
+ * snapshots/claims may stamp as `readAt`. The bare stream cursor made a
28
+ * claim taken right after an ack-confirmed write stale against that write's
29
+ * own delta; the bare ack would be wrong for read-only clients. Per-entity
30
+ * correct: a foreign change to an entity we just wrote necessarily lands
31
+ * ABOVE our ack and still stale-rejects.
32
+ *
33
+ * The Zod schema IS the state shape — the class holds exactly one
34
+ * `SyncPositionSnapshot` and applies monotonic merges to it, so
35
+ * snapshot/restore are identity-shaped and the schema is the single gate
36
+ * for anything loaded from disk (`parseSyncPosition`; a corrupted stored
37
+ * cursor "ahead of reality" is an existing, known failure mode).
38
+ */
39
+ import { z } from 'zod';
40
+ export declare const syncPositionSchema: z.ZodObject<{
41
+ persisted: z.ZodNumber;
42
+ applied: z.ZodNumber;
43
+ acked: z.ZodNumber;
44
+ }, z.core.$strip>;
45
+ export type SyncPositionSnapshot = z.infer<typeof syncPositionSchema>;
46
+ /**
47
+ * PERSISTENCE DESIGN: only the `persisted` cursor is stored durably (as
48
+ * `WorkspaceMetadata.lastSyncId`, written by Database after each IDB delta
49
+ * commit and gated on load through `syncPositionSchema.shape.persisted` in
50
+ * `Database.requiredBootstrap`). Persisting `applied`/`acked` would be
51
+ * meaningless: on resume the pool is rebuilt FROM the persisted state, so
52
+ * the correct restore is exactly `advancePersisted(storedCursor)` — which
53
+ * implies `applied`, while `acked` starts at 0 (a dead session's acks carry
54
+ * no read authority; the offline queue re-acks its own replays).
55
+ */
56
+ /** Validate a persisted/foreign value into a position snapshot. */
57
+ export declare function parseSyncPosition(value: unknown): SyncPositionSnapshot | null;
58
+ /** The live position. One instance per client (owned by SyncClient); the
59
+ * three producers advance their own fact, consumers read. */
60
+ export declare class SyncPosition {
61
+ #private;
62
+ /** Current state — the schema shape, frozen-by-copy. */
63
+ snapshot(): SyncPositionSnapshot;
64
+ get persisted(): number;
65
+ get applied(): number;
66
+ get acked(): number;
67
+ /** THE value snapshots/claims stamp as `readAt`. */
68
+ get readFloor(): number;
69
+ /** Deltas through `syncId` have COMMITTED to IndexedDB. Persisting
70
+ * implies applied — the flush path applies before/with persisting. */
71
+ advancePersisted(syncId: number): void;
72
+ /** A delta was APPLIED to the in-memory pool. */
73
+ advanceApplied(syncId: number): void;
74
+ /** The server acked one of OUR commits at this watermark. */
75
+ noteAck(lastSyncId: number | undefined): void;
76
+ /** Restore from a VALIDATED snapshot (e.g. IDB resume). Monotonic. */
77
+ restore(snapshot: SyncPositionSnapshot): void;
78
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * THE sync-position structure — one typed object for "where is this client
3
+ * in the global delta order", replacing five scattered private counters
4
+ * (`lastSeenSyncId` on the queue, `highestProcessedSyncId` + `lastAckedId`
5
+ * on the store, ad-hoc acked watermarks, `max()` calls at snapshot sites).
6
+ *
7
+ * Three facts with DIFFERENT advance disciplines — flattening them was the
8
+ * historical bug source, so the structure models them explicitly:
9
+ *
10
+ * - `persisted` — the resume/ack cursor. Advances ONLY after deltas have
11
+ * committed to IndexedDB (the Replicache "lastMutationID read in the
12
+ * same transaction as the client view" rule — see SyncWebSocket.sendAck).
13
+ * This is what reconnect catch-up sends; it must never run ahead of
14
+ * durable state or the server skips deltas that never landed.
15
+ *
16
+ * - `applied` — the in-memory cursor: the last delta APPLIED to the
17
+ * object pool. Drives delta dedup/replay guards. May run ahead of
18
+ * `persisted` (pool applies before the IDB flush) and behind receipt
19
+ * (bootstrap-queued deltas are received but not yet applied).
20
+ *
21
+ * - `acked` — the highest server watermark ACKED to this client's OWN
22
+ * commits. An ack at N means the server applied our write at N; the
23
+ * optimistic pool already reflects it, so for entities we wrote we have
24
+ * logically read through N even before the stream echo arrives.
25
+ *
26
+ * One derived read: `readFloor` = max(applied, acked) — the ONLY value
27
+ * snapshots/claims may stamp as `readAt`. The bare stream cursor made a
28
+ * claim taken right after an ack-confirmed write stale against that write's
29
+ * own delta; the bare ack would be wrong for read-only clients. Per-entity
30
+ * correct: a foreign change to an entity we just wrote necessarily lands
31
+ * ABOVE our ack and still stale-rejects.
32
+ *
33
+ * The Zod schema IS the state shape — the class holds exactly one
34
+ * `SyncPositionSnapshot` and applies monotonic merges to it, so
35
+ * snapshot/restore are identity-shaped and the schema is the single gate
36
+ * for anything loaded from disk (`parseSyncPosition`; a corrupted stored
37
+ * cursor "ahead of reality" is an existing, known failure mode).
38
+ */
39
+ import { z } from 'zod';
40
+ export const syncPositionSchema = z.object({
41
+ /** Resume/ack cursor — advances only after IDB persistence. */
42
+ persisted: z.number().int().nonnegative(),
43
+ /** In-memory cursor — last delta applied to the pool. */
44
+ applied: z.number().int().nonnegative(),
45
+ /** Highest server watermark acked to this client's own commits. */
46
+ acked: z.number().int().nonnegative(),
47
+ });
48
+ /**
49
+ * PERSISTENCE DESIGN: only the `persisted` cursor is stored durably (as
50
+ * `WorkspaceMetadata.lastSyncId`, written by Database after each IDB delta
51
+ * commit and gated on load through `syncPositionSchema.shape.persisted` in
52
+ * `Database.requiredBootstrap`). Persisting `applied`/`acked` would be
53
+ * meaningless: on resume the pool is rebuilt FROM the persisted state, so
54
+ * the correct restore is exactly `advancePersisted(storedCursor)` — which
55
+ * implies `applied`, while `acked` starts at 0 (a dead session's acks carry
56
+ * no read authority; the offline queue re-acks its own replays).
57
+ */
58
+ /** Validate a persisted/foreign value into a position snapshot. */
59
+ export function parseSyncPosition(value) {
60
+ const result = syncPositionSchema.safeParse(value);
61
+ return result.success ? result.data : null;
62
+ }
63
+ const ZERO = { persisted: 0, applied: 0, acked: 0 };
64
+ /** Monotonic merge: each cursor only ever moves forward. */
65
+ function advance(state, next) {
66
+ return {
67
+ persisted: Math.max(state.persisted, next.persisted ?? 0),
68
+ applied: Math.max(state.applied, next.applied ?? 0),
69
+ acked: Math.max(state.acked, next.acked ?? 0),
70
+ };
71
+ }
72
+ /** The live position. One instance per client (owned by SyncClient); the
73
+ * three producers advance their own fact, consumers read. */
74
+ export class SyncPosition {
75
+ #state = ZERO;
76
+ /** Current state — the schema shape, frozen-by-copy. */
77
+ snapshot() {
78
+ return { ...this.#state };
79
+ }
80
+ get persisted() {
81
+ return this.#state.persisted;
82
+ }
83
+ get applied() {
84
+ return this.#state.applied;
85
+ }
86
+ get acked() {
87
+ return this.#state.acked;
88
+ }
89
+ /** THE value snapshots/claims stamp as `readAt`. */
90
+ get readFloor() {
91
+ return Math.max(this.#state.applied, this.#state.acked);
92
+ }
93
+ /** Deltas through `syncId` have COMMITTED to IndexedDB. Persisting
94
+ * implies applied — the flush path applies before/with persisting. */
95
+ advancePersisted(syncId) {
96
+ this.#state = advance(this.#state, { persisted: syncId, applied: syncId });
97
+ }
98
+ /** A delta was APPLIED to the in-memory pool. */
99
+ advanceApplied(syncId) {
100
+ this.#state = advance(this.#state, { applied: syncId });
101
+ }
102
+ /** The server acked one of OUR commits at this watermark. */
103
+ noteAck(lastSyncId) {
104
+ if (lastSyncId !== undefined)
105
+ this.#state = advance(this.#state, { acked: lastSyncId });
106
+ }
107
+ /** Restore from a VALIDATED snapshot (e.g. IDB resume). Monotonic. */
108
+ restore(snapshot) {
109
+ this.#state = advance(this.#state, snapshot);
110
+ }
111
+ }
@@ -10,7 +10,8 @@
10
10
  import { EventEmitter } from 'events';
11
11
  import type { Database } from '../Database.js';
12
12
  import { Model } from '../Model.js';
13
- import type { MutationOptions } from '../interfaces/index.js';
13
+ import { SyncPosition } from '../sync/syncPosition.js';
14
+ import type { WriteOptions } from '../interfaces/index.js';
14
15
  export interface UserContext {
15
16
  userId: string;
16
17
  organizationId: string;
@@ -19,7 +20,6 @@ export interface UserContext {
19
20
  }
20
21
  /** Wire-format mutation payload (post-projection). */
21
22
  type MutationInput = Record<string, unknown>;
22
- type TransactionWriteOptions = Pick<MutationOptions, 'readAt' | 'onStale'>;
23
23
  export interface Transaction {
24
24
  id: string;
25
25
  type: 'create' | 'update' | 'delete' | 'archive' | 'unarchive';
@@ -34,7 +34,7 @@ export interface Transaction {
34
34
  attempts: number;
35
35
  priority: 'normal' | 'high';
36
36
  priorityScore: number;
37
- writeOptions?: TransactionWriteOptions;
37
+ writeOptions?: WriteOptions;
38
38
  batchId?: string;
39
39
  /** LINEAR PATTERN: syncId threshold - transaction confirms when delta.id >= this value */
40
40
  syncIdNeededForCompletion?: number;
@@ -83,6 +83,8 @@ interface ConflictResolution {
83
83
  resolver?: (local: MutationInput | undefined, remote: MutationInput) => MutationInput;
84
84
  }
85
85
  interface TransactionQueueConfig {
86
+ /** Shared client position (see sync/syncPosition.ts). One per client. */
87
+ position?: SyncPosition;
86
88
  maxBatchSize: number;
87
89
  batchDelay: number;
88
90
  maxRetries: number;
@@ -141,7 +143,17 @@ export declare class TransactionQueue extends EventEmitter {
141
143
  private deltaConfirmationRetries;
142
144
  private isConnectedFn;
143
145
  private commitOfflineGraceTimer;
144
- private lastSeenSyncId;
146
+ /**
147
+ * THE client's place in the global delta order — the SHARED instance
148
+ * (injected by SyncClient; standalone construction gets its own). The
149
+ * queue advances `acked` on commit responses; the store advances
150
+ * `applied`/`persisted`; snapshots/claims read `readFloor`. Contract +
151
+ * rationale live in `sync/syncPosition.ts`.
152
+ */
153
+ readonly position: SyncPosition;
154
+ /** Applied-cursor alias, kept so the many internal read sites stay legible. */
155
+ private get lastSeenSyncId();
156
+ private noteAck;
145
157
  private static readonly DELTA_MAX_RETRIES;
146
158
  private static readonly DELTA_INITIAL_TIMEOUT_MS;
147
159
  private static readonly DELTA_MAX_TIMEOUT_MS;
@@ -237,16 +249,16 @@ export declare class TransactionQueue extends EventEmitter {
237
249
  /**
238
250
  * Create operation with optimistic update
239
251
  */
240
- create(model: Model, context: UserContext, writeOptions?: TransactionWriteOptions): Promise<Transaction>;
252
+ create(model: Model, context: UserContext, writeOptions?: WriteOptions): Promise<Transaction>;
241
253
  /**
242
254
  * Update operation with conflict detection
243
255
  * @param precomputedChanges - Optional pre-captured changes (avoids re-reading from model)
244
256
  */
245
- update(model: Model, context: UserContext, precomputedChanges?: Record<string, unknown>, writeOptions?: TransactionWriteOptions): Promise<Transaction>;
257
+ update(model: Model, context: UserContext, precomputedChanges?: Record<string, unknown>, writeOptions?: WriteOptions): Promise<Transaction>;
246
258
  /**
247
259
  * Delete operation with cascade handling
248
260
  */
249
- delete(model: Model, context: UserContext, writeOptions?: TransactionWriteOptions): Promise<Transaction>;
261
+ delete(model: Model, context: UserContext, writeOptions?: WriteOptions): Promise<Transaction>;
250
262
  /**
251
263
  * Upload attachment — delegates to attachment-uploader.ts
252
264
  */
@@ -269,7 +281,7 @@ export declare class TransactionQueue extends EventEmitter {
269
281
  /**
270
282
  * Archive operation
271
283
  */
272
- archive(model: Model, context: UserContext, writeOptions?: TransactionWriteOptions): Promise<Transaction>;
284
+ archive(model: Model, context: UserContext, writeOptions?: WriteOptions): Promise<Transaction>;
273
285
  /**
274
286
  * Unarchive operation
275
287
  */
@@ -415,6 +427,8 @@ export declare class TransactionQueue extends EventEmitter {
415
427
  totalTransactions: number;
416
428
  batchIndex: number;
417
429
  config: {
430
+ /** Shared client position (see sync/syncPosition.ts). One per client. */
431
+ position?: SyncPosition;
418
432
  maxBatchSize: number;
419
433
  batchDelay: number;
420
434
  maxRetries: number;