@abloatai/ablo 0.7.0 → 0.9.0

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 (181) hide show
  1. package/CHANGELOG.md +72 -1
  2. package/README.md +80 -66
  3. package/dist/BaseSyncedStore.d.ts +73 -0
  4. package/dist/BaseSyncedStore.js +179 -5
  5. package/dist/Model.d.ts +42 -0
  6. package/dist/Model.js +103 -44
  7. package/dist/SyncEngineContext.d.ts +2 -1
  8. package/dist/SyncEngineContext.js +5 -3
  9. package/dist/agent/session.js +6 -5
  10. package/dist/ai-sdk/coordination-context.js +4 -0
  11. package/dist/ai-sdk/index.d.ts +56 -47
  12. package/dist/ai-sdk/index.js +56 -47
  13. package/dist/ai-sdk/intent-broadcast.d.ts +5 -0
  14. package/dist/ai-sdk/intent-broadcast.js +11 -4
  15. package/dist/ai-sdk/wrap.d.ts +14 -11
  16. package/dist/ai-sdk/wrap.js +11 -13
  17. package/dist/auth/credentialSource.d.ts +34 -0
  18. package/dist/auth/credentialSource.js +63 -0
  19. package/dist/auth/index.d.ts +2 -22
  20. package/dist/auth/index.js +26 -36
  21. package/dist/auth/schemas.d.ts +35 -0
  22. package/dist/auth/schemas.js +53 -0
  23. package/dist/client/Ablo.d.ts +259 -33
  24. package/dist/client/Ablo.js +276 -73
  25. package/dist/client/ApiClient.d.ts +52 -4
  26. package/dist/client/ApiClient.js +236 -66
  27. package/dist/client/auth.d.ts +21 -2
  28. package/dist/client/auth.js +77 -5
  29. package/dist/client/createInternalComponents.d.ts +2 -0
  30. package/dist/client/createInternalComponents.js +8 -1
  31. package/dist/client/createModelProxy.d.ts +187 -79
  32. package/dist/client/createModelProxy.js +203 -68
  33. package/dist/client/httpClient.d.ts +71 -0
  34. package/dist/client/httpClient.js +69 -0
  35. package/dist/client/identity.d.ts +2 -6
  36. package/dist/client/identity.js +63 -11
  37. package/dist/client/index.d.ts +1 -0
  38. package/dist/client/index.js +1 -0
  39. package/dist/client/registerDataSource.d.ts +19 -0
  40. package/dist/client/registerDataSource.js +59 -0
  41. package/dist/client/validateAbloOptions.d.ts +2 -1
  42. package/dist/client/validateAbloOptions.js +8 -7
  43. package/dist/core/DatabaseManager.js +30 -2
  44. package/dist/core/openIDBWithTimeout.d.ts +36 -0
  45. package/dist/core/openIDBWithTimeout.js +88 -1
  46. package/dist/errorCodes.d.ts +92 -1
  47. package/dist/errorCodes.js +139 -7
  48. package/dist/errors.d.ts +54 -3
  49. package/dist/errors.js +192 -44
  50. package/dist/index.d.ts +23 -10
  51. package/dist/index.js +21 -8
  52. package/dist/keys/index.d.ts +76 -0
  53. package/dist/keys/index.js +171 -0
  54. package/dist/mutators/UndoManager.d.ts +86 -50
  55. package/dist/mutators/UndoManager.js +129 -22
  56. package/dist/mutators/inverseOp.d.ts +129 -0
  57. package/dist/mutators/inverseOp.js +74 -0
  58. package/dist/mutators/readerActions.d.ts +1 -1
  59. package/dist/mutators/undoApply.d.ts +42 -0
  60. package/dist/mutators/undoApply.js +143 -0
  61. package/dist/query/client.d.ts +10 -9
  62. package/dist/query/client.js +22 -14
  63. package/dist/react/AbloProvider.d.ts +23 -101
  64. package/dist/react/AbloProvider.js +61 -103
  65. package/dist/react/ClientSideSuspense.d.ts +1 -1
  66. package/dist/react/DefaultFallback.d.ts +1 -1
  67. package/dist/react/SyncGroupProvider.d.ts +1 -1
  68. package/dist/react/index.d.ts +3 -2
  69. package/dist/react/index.js +3 -2
  70. package/dist/react/useAblo.d.ts +4 -4
  71. package/dist/react/useAblo.js +10 -5
  72. package/dist/react/useCurrentUserId.d.ts +1 -1
  73. package/dist/react/useCurrentUserId.js +1 -1
  74. package/dist/react/useMutators.js +19 -12
  75. package/dist/react/useReactive.js +16 -3
  76. package/dist/schema/ddl.d.ts +26 -3
  77. package/dist/schema/ddl.js +152 -4
  78. package/dist/schema/index.d.ts +4 -0
  79. package/dist/schema/index.js +12 -0
  80. package/dist/schema/model.d.ts +11 -0
  81. package/dist/schema/model.js +2 -0
  82. package/dist/schema/openapi.d.ts +28 -0
  83. package/dist/schema/openapi.js +118 -0
  84. package/dist/schema/plane.d.ts +23 -0
  85. package/dist/schema/plane.js +19 -0
  86. package/dist/schema/relation.d.ts +20 -0
  87. package/dist/schema/serialize.d.ts +7 -3
  88. package/dist/schema/serialize.js +6 -2
  89. package/dist/schema/sync-delta-row.d.ts +157 -0
  90. package/dist/schema/sync-delta-row.js +102 -0
  91. package/dist/schema/sync-delta-wire.d.ts +180 -0
  92. package/dist/schema/sync-delta-wire.js +102 -0
  93. package/dist/server/adapter.d.ts +156 -0
  94. package/dist/server/adapter.js +19 -0
  95. package/dist/server/commit.d.ts +82 -0
  96. package/dist/server/commit.js +1 -0
  97. package/dist/server/index.d.ts +14 -0
  98. package/dist/server/index.js +1 -0
  99. package/dist/server/next.d.ts +51 -0
  100. package/dist/server/next.js +47 -0
  101. package/dist/server/read-config.d.ts +60 -0
  102. package/dist/server/read-config.js +8 -0
  103. package/dist/server/storage-mode.d.ts +17 -0
  104. package/dist/server/storage-mode.js +12 -0
  105. package/dist/source/adapter.d.ts +59 -0
  106. package/dist/source/adapter.js +19 -0
  107. package/dist/source/adapters/drizzle.d.ts +34 -0
  108. package/dist/source/adapters/drizzle.js +147 -0
  109. package/dist/source/adapters/memory.d.ts +12 -0
  110. package/dist/source/adapters/memory.js +114 -0
  111. package/dist/source/adapters/prisma.d.ts +57 -0
  112. package/dist/source/adapters/prisma.js +199 -0
  113. package/dist/source/conformance.d.ts +32 -0
  114. package/dist/source/conformance.js +134 -0
  115. package/dist/source/contract.d.ts +143 -0
  116. package/dist/source/contract.js +98 -0
  117. package/dist/source/index.d.ts +61 -10
  118. package/dist/source/index.js +98 -0
  119. package/dist/source/next.d.ts +33 -0
  120. package/dist/source/next.js +26 -0
  121. package/dist/sync/BootstrapHelper.d.ts +10 -0
  122. package/dist/sync/BootstrapHelper.js +56 -42
  123. package/dist/sync/ConnectionManager.d.ts +57 -1
  124. package/dist/sync/ConnectionManager.js +186 -11
  125. package/dist/sync/HydrationCoordinator.d.ts +93 -17
  126. package/dist/sync/HydrationCoordinator.js +241 -41
  127. package/dist/sync/NetworkProbe.d.ts +60 -18
  128. package/dist/sync/NetworkProbe.js +121 -23
  129. package/dist/sync/SyncWebSocket.d.ts +45 -70
  130. package/dist/sync/SyncWebSocket.js +113 -89
  131. package/dist/sync/createIntentStream.js +10 -1
  132. package/dist/sync/participants.js +5 -2
  133. package/dist/transactions/TransactionQueue.js +13 -1
  134. package/dist/types/streams.d.ts +9 -0
  135. package/dist/utils/mobx-setup.js +1 -0
  136. package/dist/webhooks/events.d.ts +38 -0
  137. package/dist/webhooks/events.js +40 -0
  138. package/dist/webhooks/index.d.ts +10 -0
  139. package/dist/webhooks/index.js +10 -0
  140. package/dist/wire/errorEnvelope.d.ts +34 -0
  141. package/dist/wire/errorEnvelope.js +86 -0
  142. package/dist/wire/frames.d.ts +119 -0
  143. package/dist/wire/frames.js +1 -0
  144. package/dist/wire/index.d.ts +24 -0
  145. package/dist/wire/index.js +21 -0
  146. package/dist/wire/listEnvelope.d.ts +45 -0
  147. package/dist/wire/listEnvelope.js +17 -0
  148. package/docs/api-keys.md +5 -5
  149. package/docs/api.md +125 -65
  150. package/docs/audit.md +16 -9
  151. package/docs/cli.md +57 -47
  152. package/docs/client-behavior.md +54 -40
  153. package/docs/coordination.md +66 -80
  154. package/docs/data-sources.md +56 -34
  155. package/docs/examples/agent-human.md +74 -28
  156. package/docs/examples/ai-sdk-tool.md +29 -22
  157. package/docs/examples/existing-python-backend.md +41 -26
  158. package/docs/examples/nextjs.md +32 -17
  159. package/docs/examples/scoped-agent.md +43 -28
  160. package/docs/examples/server-agent.md +40 -15
  161. package/docs/guarantees.md +38 -27
  162. package/docs/identity.md +65 -59
  163. package/docs/index.md +30 -19
  164. package/docs/integration-guide.md +78 -78
  165. package/docs/interaction-model.md +43 -35
  166. package/docs/mcp/claude-code.md +11 -19
  167. package/docs/mcp/cursor.md +7 -25
  168. package/docs/mcp/windsurf.md +7 -20
  169. package/docs/mcp.md +103 -26
  170. package/docs/quickstart.md +63 -61
  171. package/docs/react.md +24 -16
  172. package/docs/roadmap.md +13 -13
  173. package/docs/schema-contract.md +111 -0
  174. package/docs/the-loop.md +21 -0
  175. package/examples/README.md +8 -4
  176. package/examples/data-source/README.md +10 -7
  177. package/examples/data-source/customer-server.ts +27 -25
  178. package/examples/data-source/run.ts +4 -3
  179. package/examples/quickstart.ts +1 -1
  180. package/llms.txt +55 -21
  181. package/package.json +48 -3
package/dist/index.js CHANGED
@@ -5,8 +5,11 @@
5
5
  * import Ablo from '@abloatai/ablo';
6
6
  *
7
7
  * const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
8
- * await ablo.weatherReports.load({ where: { id: 'report_stockholm' } });
9
- * await ablo.weatherReports.update('report_stockholm', { status: 'ready' });
8
+ * const report = await ablo.weatherReports.retrieve({ id: 'report_stockholm' });
9
+ * await ablo.weatherReports.update({
10
+ * id: 'report_stockholm',
11
+ * data: { status: 'ready' },
12
+ * });
10
13
  *
11
14
  * type Entry = Ablo.Peer;
12
15
  * ```
@@ -23,13 +26,17 @@
23
26
  * @abloatai/ablo/react — <AbloProvider>, useQuery, useMutate
24
27
  * @abloatai/ablo/testing — test harnesses + mocks
25
28
  *
26
- * Consumer code should converge on `ablo.<model>.load(...)`, which routes
27
- * through the engine's `HydrationCoordinator` and dedupes single-flight
28
- * hydrations.
29
+ * Reads split by where the data comes from. `ablo.<model>.retrieve({ id })` and
30
+ * `.list({ where })` are the async **server** reads (pool → IDB → network via
31
+ * the `HydrationCoordinator`, single-flight deduped); they're the default and
32
+ * what hosted/stateless callers want, since their local graph starts empty.
33
+ * `ablo.<model>.get(id)` / `.getAll(...)` / `.getCount(...)` are synchronous
34
+ * **local-graph** snapshots with no network round-trip — for reactive React
35
+ * selectors (`useAblo((ablo) => ablo.<model>.get(id))`) once the graph is warm.
29
36
  *
30
37
  * ── What to import (read this first) ────────────────────────────────
31
38
  * Default path — this is all most apps and agents ever need:
32
- * • `Ablo` (default export) + `AbloOptions` + the `Model*Options` bags
39
+ * • `Ablo` (default export) + `AbloOptions` + the `Model*Params` bags
33
40
  * • the `Ablo*Error` classes, to discriminate failures in catch blocks
34
41
  * That's it. If you're reaching past those, you're in advanced territory.
35
42
  *
@@ -49,6 +56,8 @@
49
56
  // `import Ablo from '@abloatai/ablo'` works; named export so
50
57
  // `import { Ablo }` also compiles.
51
58
  export { Ablo } from './client/Ablo.js';
59
+ export { createAbloHttpClient, } from './client/httpClient.js';
60
+ export { ABLO_DEFAULT_BASE_URL, ABLO_HOSTED_API_DOMAIN, ABLO_HOSTED_HTTP_BASE_URL, normalizeAbloHostedBaseUrl, } from './client/auth.js';
52
61
  // Participant types live under `Ablo.Participant.*` —
53
62
  // `Ablo.Participant.Joined`, `Ablo.Participant.Manager`,
54
63
  // `Ablo.Participant.JoinOptions`, etc. Same dot-access shape as
@@ -66,7 +75,7 @@ export default Ablo;
66
75
  // storage — if you haven't deliberately chosen to keep your own DB
67
76
  // canonical, skip this entirely. Type counterparts live under
68
77
  // `Ablo.Source.*` (`Ablo.Source.Operation`, `Ablo.Source.Commit.Params`).
69
- export { dataSource, abloSource, signAbloSourceRequest, verifyAbloSourceRequest, } from './source/index.js';
78
+ export { dataSource, abloSource, sourceEventForOperation, signAbloSourceRequest, verifyAbloSourceRequest, } from './source/index.js';
70
79
  // Schema DSL is intentionally published from `@abloatai/ablo/schema`.
71
80
  // Keeping it out of the root import preserves one clean runtime surface:
72
81
  // `import Ablo from '@abloatai/ablo'`.
@@ -78,7 +87,11 @@ export { defaultPolicy, capabilityPreemptPolicy } from './policy/index.js';
78
87
  // Typed error hierarchy — Stripe-style. One import gets every class
79
88
  // consumers need to discriminate failures (`e instanceof AbloX` or
80
89
  // `e.type === 'AbloX'`) plus the HTTP-response translator.
81
- export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, } from './errors.js';
90
+ export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, hasWireCode, errorFromWire, toAbloError, ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, classifyRecovery, recoveryClassSchema, RECOVERY_CLASSES, } from './errors.js';
91
+ export { WS_BEARER_SUBPROTOCOL_PREFIX, WS_SYNC_SUBPROTOCOL } from './auth/credentialSource.js';
92
+ // Storage-wedge detection — lets app shells render a recovery screen when the
93
+ // IndexedDB backing store is stuck (see core/openIDBWithTimeout.ts).
94
+ export { IDBOpenTimeoutError, isStorageOpenTimeout } from './core/openIDBWithTimeout.js';
82
95
  // Advanced — most apps never import this. Custom (Zero-style) mutators:
83
96
  // `ablo.<model>.create/update/delete` already covers normal writes. Reach
84
97
  // for `defineMutators` only when you need a named, multi-step mutation with
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Canonical Ablo API-key format — the single source of truth for how keys
3
+ * are minted, hashed, and validated. Both the sync-server (`apiKeyStore`)
4
+ * and the web control-plane (`generate-key.ts`) consume THIS module, so the
5
+ * format can no longer drift between the two mint sites (it used to live as
6
+ * a hand-copied twin kept in sync by a comment).
7
+ *
8
+ * Node-only — uses `node:crypto`. Exposed via the `@abloatai/ablo/keys`
9
+ * subpath and NEVER re-exported from the browser-facing `.` entry, so the
10
+ * client bundle never pulls in `node:crypto`.
11
+ *
12
+ * Format (GitHub-style): `<sk|rk|ek>_<live|test>_<30 base62 body><6-char
13
+ * base62 CRC32 checksum>`. The identifiable prefix + CRC32 checksum let
14
+ * secret scanners detect leaks and let us reject typo'd/forged keys OFFLINE
15
+ * (no DB round-trip). Legacy keys (a ~43-char base64url body, no checksum)
16
+ * still validate by hash — they parse here as `checksummed: false`.
17
+ */
18
+ import { z } from 'zod';
19
+ export declare const API_KEY_KINDS: readonly ["secret", "restricted", "ephemeral", "publishable"];
20
+ export type ApiKeyKind = (typeof API_KEY_KINDS)[number];
21
+ export declare const API_KEY_ENVS: readonly ["live", "test"];
22
+ export type ApiKeyEnv = (typeof API_KEY_ENVS)[number];
23
+ /** A structurally-valid Ablo API key, parsed into its parts. */
24
+ export interface ParsedApiKey {
25
+ /** The original plaintext. */
26
+ raw: string;
27
+ kind: ApiKeyKind;
28
+ env: ApiKeyEnv;
29
+ /** The chars after `<prefix>_<env>_` (body + checksum for new keys). */
30
+ body: string;
31
+ /** True when this is the new checksummed format (36-char base62 body). */
32
+ checksummed: boolean;
33
+ }
34
+ /**
35
+ * Canonical schema for an Ablo API key. `parse`/`safeParse` returns a typed
36
+ * {@link ParsedApiKey}; a new checksummed-format key with a BAD checksum is
37
+ * rejected (the offline-reject), while a legacy key parses as
38
+ * `checksummed: false` and passes (the server still hash-validates it).
39
+ */
40
+ export declare const apiKeySchema: z.ZodPipe<z.ZodString, z.ZodTransform<ParsedApiKey, string>>;
41
+ /** Parse + fully validate (incl. checksum). Returns null when invalid. */
42
+ export declare function parseApiKey(raw: string): ParsedApiKey | null;
43
+ /** True when the key uses the new checksummed format (regardless of validity). */
44
+ export declare function isChecksummedKey(raw: string): boolean;
45
+ /** Verify the embedded checksum. Meaningful only for checksummed-format keys. */
46
+ export declare function keyChecksumMatches(raw: string): boolean;
47
+ /**
48
+ * Mint a key: `<prefix>_<env>_<body><checksum>`. Returns the plaintext (shown
49
+ * once), its SHA-256 hash (persisted), and the 12-char display prefix.
50
+ */
51
+ export declare function generateApiKey(env?: ApiKeyEnv, kind?: ApiKeyKind): {
52
+ plaintext: string;
53
+ hash: string;
54
+ prefix: string;
55
+ };
56
+ /**
57
+ * Stable SHA-256 hex of a plaintext key. A fast hash is CORRECT here (not
58
+ * bcrypt) — API keys are high-entropy random, so there's no dictionary to
59
+ * defend against. Used at both write (mint) and lookup.
60
+ */
61
+ export declare function hashApiKey(plaintext: string): string;
62
+ /** `whsec_` label prefix per the Standard Webhooks spec (not part of the key material). */
63
+ export declare const WEBHOOK_SECRET_PREFIX = "whsec_";
64
+ /**
65
+ * Mint a webhook signing secret per the Standard Webhooks spec
66
+ * (https://www.standardwebhooks.com): a base64-encoded random key, 24–64 bytes,
67
+ * labelled with the `whsec_` prefix. We use 32 bytes (256 bits) — comfortably
68
+ * inside the range and matching Stripe/Svix. Unlike an API key this is NOT
69
+ * hashed at rest: signing (`signAbloSourceRequest`) needs the live key, so it is
70
+ * stored by reference via the secret store, returned to the customer once at
71
+ * creation, and never echoed again (Stripe's policy).
72
+ */
73
+ export declare function generateWebhookSecret(): {
74
+ plaintext: string;
75
+ last4: string;
76
+ };
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Canonical Ablo API-key format — the single source of truth for how keys
3
+ * are minted, hashed, and validated. Both the sync-server (`apiKeyStore`)
4
+ * and the web control-plane (`generate-key.ts`) consume THIS module, so the
5
+ * format can no longer drift between the two mint sites (it used to live as
6
+ * a hand-copied twin kept in sync by a comment).
7
+ *
8
+ * Node-only — uses `node:crypto`. Exposed via the `@abloatai/ablo/keys`
9
+ * subpath and NEVER re-exported from the browser-facing `.` entry, so the
10
+ * client bundle never pulls in `node:crypto`.
11
+ *
12
+ * Format (GitHub-style): `<sk|rk|ek>_<live|test>_<30 base62 body><6-char
13
+ * base62 CRC32 checksum>`. The identifiable prefix + CRC32 checksum let
14
+ * secret scanners detect leaks and let us reject typo'd/forged keys OFFLINE
15
+ * (no DB round-trip). Legacy keys (a ~43-char base64url body, no checksum)
16
+ * still validate by hash — they parse here as `checksummed: false`.
17
+ */
18
+ import { createHash, randomBytes } from 'node:crypto';
19
+ import { z } from 'zod';
20
+ // ── Vocabulary ──────────────────────────────────────────────────────────
21
+ // The Stripe-style key model:
22
+ // secret (sk_) — backend / server-to-server / agents. Full authority. Never in a browser.
23
+ // restricted (rk_) — scoped SERVER key (agent session tokens / capabilities).
24
+ // ephemeral (ek_) — short-lived, backend-minted, USER-scoped BROWSER session credential
25
+ // (Stripe ephemeral keys). Carries participantKind:'user' + baked syncGroups.
26
+ // publishable (pk_) — long-lived, browser-safe, org-scoped, READ-ONLY project key
27
+ // (Stripe `pk_` / Supabase anon key). Used DIRECTLY as the bearer
28
+ // (never exchanged, never expires → nothing to refresh). The org owns
29
+ // it; it grants read access to the org's data plane and cannot write
30
+ // or reach any control-plane operation.
31
+ export const API_KEY_KINDS = ['secret', 'restricted', 'ephemeral', 'publishable'];
32
+ export const API_KEY_ENVS = ['live', 'test'];
33
+ const PREFIX_BY_KIND = {
34
+ secret: 'sk',
35
+ restricted: 'rk',
36
+ ephemeral: 'ek',
37
+ publishable: 'pk',
38
+ };
39
+ const KIND_BY_PREFIX = {
40
+ sk: 'secret',
41
+ rk: 'restricted',
42
+ ek: 'ephemeral',
43
+ pk: 'publishable',
44
+ };
45
+ const BASE62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
46
+ /** Random base62 chars before the checksum. */
47
+ const KEY_BODY_LEN = 30;
48
+ /** base62(CRC32): 62^6 (~5.7e10) > 2^32, so a CRC32 always fits in 6 chars. */
49
+ const CHECKSUM_LEN = 6;
50
+ /** A new checksummed body is exactly this long and pure base62. */
51
+ const CHECKSUMMED_BODY_LEN = KEY_BODY_LEN + CHECKSUM_LEN;
52
+ /** `<sk|rk|ek|pk>_<live|test>_<body>`; body charset covers base62 AND legacy base64url. */
53
+ const KEY_RE = /^(sk|rk|ek|pk)_(live|test)_([0-9A-Za-z\-_]+)$/;
54
+ const BASE62_RE = /^[0-9A-Za-z]+$/;
55
+ // ── Checksum (standard CRC-32, GitHub-compatible) ───────────────────────
56
+ const CRC32_TABLE = (() => {
57
+ const t = new Uint32Array(256);
58
+ for (let n = 0; n < 256; n++) {
59
+ let c = n;
60
+ for (let k = 0; k < 8; k++)
61
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
62
+ t[n] = c >>> 0;
63
+ }
64
+ return t;
65
+ })();
66
+ function crc32(s) {
67
+ let c = 0xffffffff;
68
+ for (let i = 0; i < s.length; i++) {
69
+ c = (CRC32_TABLE[(c ^ s.charCodeAt(i)) & 0xff] ^ (c >>> 8)) >>> 0;
70
+ }
71
+ return (c ^ 0xffffffff) >>> 0;
72
+ }
73
+ /** 6-char base62 encoding of the CRC32 of `payload`. */
74
+ function checksum6(payload) {
75
+ let n = crc32(payload);
76
+ let out = '';
77
+ for (let i = 0; i < CHECKSUM_LEN; i++) {
78
+ out = BASE62[n % 62] + out;
79
+ n = Math.floor(n / 62);
80
+ }
81
+ return out;
82
+ }
83
+ /** `len` cryptographically-random base62 chars (rejection-sampled, no bias). */
84
+ function randomBase62(len) {
85
+ let out = '';
86
+ while (out.length < len) {
87
+ for (const b of randomBytes(len * 2)) {
88
+ if (b < 248) {
89
+ out += BASE62[b % 62];
90
+ if (out.length === len)
91
+ break;
92
+ }
93
+ }
94
+ }
95
+ return out;
96
+ }
97
+ function bodyIsChecksummed(body) {
98
+ return body.length === CHECKSUMMED_BODY_LEN && BASE62_RE.test(body);
99
+ }
100
+ /**
101
+ * Canonical schema for an Ablo API key. `parse`/`safeParse` returns a typed
102
+ * {@link ParsedApiKey}; a new checksummed-format key with a BAD checksum is
103
+ * rejected (the offline-reject), while a legacy key parses as
104
+ * `checksummed: false` and passes (the server still hash-validates it).
105
+ */
106
+ export const apiKeySchema = z.string().transform((raw, ctx) => {
107
+ const m = KEY_RE.exec(raw);
108
+ if (!m) {
109
+ ctx.addIssue({ code: 'custom', message: 'not a valid Ablo API key format' });
110
+ return z.NEVER;
111
+ }
112
+ const [, prefix, env, body] = m;
113
+ const checksummed = bodyIsChecksummed(body);
114
+ if (checksummed && checksum6(raw.slice(0, -CHECKSUM_LEN)) !== body.slice(KEY_BODY_LEN)) {
115
+ ctx.addIssue({ code: 'custom', message: 'API key checksum mismatch' });
116
+ return z.NEVER;
117
+ }
118
+ return { raw, kind: KIND_BY_PREFIX[prefix], env: env, body, checksummed };
119
+ });
120
+ // ── Derived validators (thin wrappers over the same spec) ───────────────
121
+ /** Parse + fully validate (incl. checksum). Returns null when invalid. */
122
+ export function parseApiKey(raw) {
123
+ const r = apiKeySchema.safeParse(raw);
124
+ return r.success ? r.data : null;
125
+ }
126
+ /** True when the key uses the new checksummed format (regardless of validity). */
127
+ export function isChecksummedKey(raw) {
128
+ const m = KEY_RE.exec(raw);
129
+ return m !== null && bodyIsChecksummed(m[3]);
130
+ }
131
+ /** Verify the embedded checksum. Meaningful only for checksummed-format keys. */
132
+ export function keyChecksumMatches(raw) {
133
+ const m = KEY_RE.exec(raw);
134
+ if (!m || !bodyIsChecksummed(m[3]))
135
+ return false;
136
+ return checksum6(raw.slice(0, -CHECKSUM_LEN)) === m[3].slice(KEY_BODY_LEN);
137
+ }
138
+ // ── Mint + hash (node:crypto) ───────────────────────────────────────────
139
+ /**
140
+ * Mint a key: `<prefix>_<env>_<body><checksum>`. Returns the plaintext (shown
141
+ * once), its SHA-256 hash (persisted), and the 12-char display prefix.
142
+ */
143
+ export function generateApiKey(env = 'live', kind = 'secret') {
144
+ const body = randomBase62(KEY_BODY_LEN);
145
+ const payload = `${PREFIX_BY_KIND[kind]}_${env}_${body}`;
146
+ const plaintext = `${payload}${checksum6(payload)}`;
147
+ return { plaintext, hash: hashApiKey(plaintext), prefix: plaintext.slice(0, 12) };
148
+ }
149
+ /**
150
+ * Stable SHA-256 hex of a plaintext key. A fast hash is CORRECT here (not
151
+ * bcrypt) — API keys are high-entropy random, so there's no dictionary to
152
+ * defend against. Used at both write (mint) and lookup.
153
+ */
154
+ export function hashApiKey(plaintext) {
155
+ return createHash('sha256').update(plaintext).digest('hex');
156
+ }
157
+ /** `whsec_` label prefix per the Standard Webhooks spec (not part of the key material). */
158
+ export const WEBHOOK_SECRET_PREFIX = 'whsec_';
159
+ /**
160
+ * Mint a webhook signing secret per the Standard Webhooks spec
161
+ * (https://www.standardwebhooks.com): a base64-encoded random key, 24–64 bytes,
162
+ * labelled with the `whsec_` prefix. We use 32 bytes (256 bits) — comfortably
163
+ * inside the range and matching Stripe/Svix. Unlike an API key this is NOT
164
+ * hashed at rest: signing (`signAbloSourceRequest`) needs the live key, so it is
165
+ * stored by reference via the secret store, returned to the customer once at
166
+ * creation, and never echoed again (Stripe's policy).
167
+ */
168
+ export function generateWebhookSecret() {
169
+ const plaintext = `${WEBHOOK_SECRET_PREFIX}${randomBytes(32).toString('base64')}`;
170
+ return { plaintext, last4: plaintext.slice(-4) };
171
+ }
@@ -19,56 +19,21 @@
19
19
  */
20
20
  import type { Schema } from '../schema/schema.js';
21
21
  import type { SyncStoreContract } from '../react/context.js';
22
- /**
23
- * A single reversible operation. The runtime captures these during a
24
- * recorded transaction and replays them (in reverse order) on undo.
25
- * Model keys and data shapes are stored as strings/records so the manager
26
- * is schema-agnostic — the transaction it replays through is schema-typed.
27
- */
28
- export type InverseOp = {
29
- kind: 'create';
30
- modelKey: string;
31
- data: Record<string, unknown>;
32
- } | {
33
- kind: 'update';
34
- modelKey: string;
35
- patch: {
36
- id: string;
37
- } & Record<string, unknown>;
38
- } | {
39
- kind: 'delete';
40
- modelKey: string;
41
- id: string;
42
- } | {
43
- kind: 'createMany';
44
- modelKey: string;
45
- data: Record<string, unknown>[];
46
- } | {
47
- kind: 'updateMany';
48
- modelKey: string;
49
- patches: Array<{
50
- id: string;
51
- } & Record<string, unknown>>;
52
- } | {
53
- kind: 'deleteMany';
54
- modelKey: string;
55
- ids: string[];
56
- };
57
- /** One undo entry = one mutator invocation's set of inverses, in reverse order. */
58
- export interface UndoEntry {
59
- /** Optional label for diagnostics / UI ("Move layer", "Delete slide", etc). */
60
- label?: string;
61
- inverses: InverseOp[];
62
- /**
63
- * Paired forward ops, captured at record time so redo can replay them
64
- * without re-running the user's mutator (which may have non-idempotent
65
- * side effects like generating new IDs).
66
- */
67
- forwards: InverseOp[];
68
- }
22
+ import { type InverseOp, type UndoEntry } from './inverseOp.js';
23
+ import { type UndoConflictPolicy } from './undoApply.js';
24
+ export type { InverseOp, UndoEntry };
25
+ export type { UndoConflictPolicy } from './undoApply.js';
69
26
  export interface UndoScopeOptions {
70
27
  /** Max number of undo entries. Older entries drop off the bottom. Default: 100. */
71
28
  maxHistory?: number;
29
+ /**
30
+ * How undo/redo treats a field a collaborator changed after your op.
31
+ * Default `skip-stale` — your undo reverts your change only where it still
32
+ * stands, never clobbering a concurrent collaborator edit (per-user undo).
33
+ * `last-writer-wins` restores the legacy clobbering behavior. See
34
+ * {@link UndoConflictPolicy}.
35
+ */
36
+ conflictPolicy?: UndoConflictPolicy;
72
37
  }
73
38
  /**
74
39
  * A single undo stack for one surface. Access via `UndoManager.getScope(name)`.
@@ -82,14 +47,85 @@ export declare class UndoScope<S extends Schema> {
82
47
  private undoStack;
83
48
  private redoStack;
84
49
  private readonly maxHistory;
50
+ private readonly conflictPolicy;
51
+ /**
52
+ * Observers notified after each successful {@link record}. These see FORWARD
53
+ * user actions only — `undo()`/`redo()` replays move entries between stacks
54
+ * without calling `record()`, so a listener never observes a reversal. This
55
+ * is a deliberately domain-agnostic seam: analytics, gamification, and audit
56
+ * can tap the committed-mutation stream without the scope knowing about them.
57
+ * A throwing listener is isolated (see {@link emitRecord}) so a faulty
58
+ * observer can never wedge the editor's recording path.
59
+ */
60
+ private readonly recordListeners;
61
+ /**
62
+ * Serialization tail. Recording, undo, and redo all chain off this single
63
+ * promise so they run strictly in the order they were *invoked* — never
64
+ * interleaved. This is load-bearing for correctness, not just throughput:
65
+ * - Ordering: callers fire writes un-awaited (`void mutations.x.update`).
66
+ * Without serialization, an entry lands on the stack when its mutator
67
+ * *resolves*, so a fast second write can record before a slow first one
68
+ * → undo replays in the wrong order.
69
+ * - Snapshot integrity: every recording reads/clears the shared models'
70
+ * `modifiedProperties` (the undo "before" baseline). Two recordings
71
+ * interleaving on the same model corrupt each other's inverse snapshot.
72
+ * Serializing the whole scope closes both holes with one mechanism.
73
+ */
74
+ private tail;
85
75
  constructor(schema: S, store: SyncStoreContract, organizationId: string, options?: UndoScopeOptions);
86
- /** Internal: record a mutator's inverses. Clears the redo stack. */
76
+ /**
77
+ * Run `work` after every previously-enqueued scope operation has settled,
78
+ * in invocation order. The internal `tail` always resolves (failures are
79
+ * swallowed *for the chain only*) so one rejected mutator can't wedge the
80
+ * queue; the original settlement is still surfaced to this call's caller.
81
+ */
82
+ private enqueue;
83
+ /**
84
+ * Run a recording mutator exclusively on the scope's serialization chain.
85
+ * `useMutators` calls this so the snapshot → write → `record()` sequence is
86
+ * atomic relative to other invocations, undo, and redo.
87
+ */
88
+ runRecorded<T>(work: () => Promise<T>): Promise<T>;
89
+ /**
90
+ * Internal: record a mutator's inverses. Clears the redo stack.
91
+ *
92
+ * Entries here are produced internally by `RecordingTransaction` (trusted),
93
+ * so the schema check is DEV-ONLY: it catches recorder bugs in dev/test
94
+ * (rejecting a malformed op at ingestion, with its path, instead of letting
95
+ * it crash later inside `applyOps`) without paying a Zod parse on every user
96
+ * action in production. The real validation boundary is `parseUndoEntry`,
97
+ * applied when entries are deserialized from persistence (untrusted input).
98
+ * Best practice: validate at trust boundaries, type-check internal calls.
99
+ */
87
100
  record(entry: UndoEntry): void;
101
+ /**
102
+ * Subscribe to every recorded mutation. Fires synchronously at the tail of
103
+ * each {@link record} call, after the entry is on the undo stack. Returns an
104
+ * unsubscribe function — call it on teardown.
105
+ *
106
+ * Listeners receive the full {@link UndoEntry} (its `forwards` carry the
107
+ * `{ kind, modelKey, data }` ops), so a consumer can derive what changed
108
+ * (e.g. "a slideLayers row of type 'chart' was created") without re-querying.
109
+ */
110
+ onRecord(listener: (entry: UndoEntry) => void): () => void;
111
+ private emitRecord;
88
112
  canUndo(): boolean;
89
113
  canRedo(): boolean;
90
- /** Pop the last mutator and apply its inverses. Pushes to redo. */
114
+ /**
115
+ * Pop the last mutator and apply its inverses. Pushes to redo.
116
+ *
117
+ * Under the default `skip-stale` policy the inverses are filtered against
118
+ * live state first (paired with the entry's forwards = "what I set"), so a
119
+ * field a collaborator changed after my op is left untouched — undo reverts
120
+ * my change only where it still stands.
121
+ */
91
122
  undo(): Promise<void>;
92
- /** Pop the last undone entry and re-apply the forward ops. Pushes to undo. */
123
+ /**
124
+ * Pop the last undone entry and re-apply the forward ops. Pushes to undo.
125
+ * Symmetric to {@link undo}: forwards are filtered against live state
126
+ * (paired with the entry's inverses = "what undo restored"), so redo
127
+ * re-asserts my change only where the undone value still stands.
128
+ */
93
129
  redo(): Promise<void>;
94
130
  /** Drop all history. Use after bootstrap / sync group change / sync error. */
95
131
  clear(): void;
@@ -18,6 +18,8 @@
18
18
  * the scope on sync error if they want strict correctness.
19
19
  */
20
20
  import { createTransaction } from './Transaction.js';
21
+ import { parseUndoEntry } from './inverseOp.js';
22
+ import { resolveOps, DEFAULT_UNDO_CONFLICT_POLICY, } from './undoApply.js';
21
23
  /**
22
24
  * A single undo stack for one surface. Access via `UndoManager.getScope(name)`.
23
25
  * Consumers call `record(entry)` after each mutator; `undo()` / `redo()` to
@@ -30,18 +32,105 @@ export class UndoScope {
30
32
  undoStack = [];
31
33
  redoStack = [];
32
34
  maxHistory;
35
+ conflictPolicy;
36
+ /**
37
+ * Observers notified after each successful {@link record}. These see FORWARD
38
+ * user actions only — `undo()`/`redo()` replays move entries between stacks
39
+ * without calling `record()`, so a listener never observes a reversal. This
40
+ * is a deliberately domain-agnostic seam: analytics, gamification, and audit
41
+ * can tap the committed-mutation stream without the scope knowing about them.
42
+ * A throwing listener is isolated (see {@link emitRecord}) so a faulty
43
+ * observer can never wedge the editor's recording path.
44
+ */
45
+ recordListeners = new Set();
46
+ /**
47
+ * Serialization tail. Recording, undo, and redo all chain off this single
48
+ * promise so they run strictly in the order they were *invoked* — never
49
+ * interleaved. This is load-bearing for correctness, not just throughput:
50
+ * - Ordering: callers fire writes un-awaited (`void mutations.x.update`).
51
+ * Without serialization, an entry lands on the stack when its mutator
52
+ * *resolves*, so a fast second write can record before a slow first one
53
+ * → undo replays in the wrong order.
54
+ * - Snapshot integrity: every recording reads/clears the shared models'
55
+ * `modifiedProperties` (the undo "before" baseline). Two recordings
56
+ * interleaving on the same model corrupt each other's inverse snapshot.
57
+ * Serializing the whole scope closes both holes with one mechanism.
58
+ */
59
+ tail = Promise.resolve();
33
60
  constructor(schema, store, organizationId, options = {}) {
34
61
  this.schema = schema;
35
62
  this.store = store;
36
63
  this.organizationId = organizationId;
37
64
  this.maxHistory = options.maxHistory ?? 100;
65
+ this.conflictPolicy = options.conflictPolicy ?? DEFAULT_UNDO_CONFLICT_POLICY;
38
66
  }
39
- /** Internal: record a mutator's inverses. Clears the redo stack. */
67
+ /**
68
+ * Run `work` after every previously-enqueued scope operation has settled,
69
+ * in invocation order. The internal `tail` always resolves (failures are
70
+ * swallowed *for the chain only*) so one rejected mutator can't wedge the
71
+ * queue; the original settlement is still surfaced to this call's caller.
72
+ */
73
+ enqueue(work) {
74
+ const result = this.tail.then(work, work);
75
+ this.tail = result.then(() => undefined, () => undefined);
76
+ return result;
77
+ }
78
+ /**
79
+ * Run a recording mutator exclusively on the scope's serialization chain.
80
+ * `useMutators` calls this so the snapshot → write → `record()` sequence is
81
+ * atomic relative to other invocations, undo, and redo.
82
+ */
83
+ runRecorded(work) {
84
+ return this.enqueue(work);
85
+ }
86
+ /**
87
+ * Internal: record a mutator's inverses. Clears the redo stack.
88
+ *
89
+ * Entries here are produced internally by `RecordingTransaction` (trusted),
90
+ * so the schema check is DEV-ONLY: it catches recorder bugs in dev/test
91
+ * (rejecting a malformed op at ingestion, with its path, instead of letting
92
+ * it crash later inside `applyOps`) without paying a Zod parse on every user
93
+ * action in production. The real validation boundary is `parseUndoEntry`,
94
+ * applied when entries are deserialized from persistence (untrusted input).
95
+ * Best practice: validate at trust boundaries, type-check internal calls.
96
+ */
40
97
  record(entry) {
98
+ if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
99
+ parseUndoEntry(entry);
100
+ }
41
101
  this.undoStack.push(entry);
42
102
  if (this.undoStack.length > this.maxHistory)
43
103
  this.undoStack.shift();
44
104
  this.redoStack = [];
105
+ this.emitRecord(entry);
106
+ }
107
+ /**
108
+ * Subscribe to every recorded mutation. Fires synchronously at the tail of
109
+ * each {@link record} call, after the entry is on the undo stack. Returns an
110
+ * unsubscribe function — call it on teardown.
111
+ *
112
+ * Listeners receive the full {@link UndoEntry} (its `forwards` carry the
113
+ * `{ kind, modelKey, data }` ops), so a consumer can derive what changed
114
+ * (e.g. "a slideLayers row of type 'chart' was created") without re-querying.
115
+ */
116
+ onRecord(listener) {
117
+ this.recordListeners.add(listener);
118
+ return () => {
119
+ this.recordListeners.delete(listener);
120
+ };
121
+ }
122
+ emitRecord(entry) {
123
+ for (const listener of this.recordListeners) {
124
+ try {
125
+ listener(entry);
126
+ }
127
+ catch (err) {
128
+ // A faulty observer must never break the editor's recording path.
129
+ if (typeof console !== 'undefined') {
130
+ console.error('[UndoScope] onRecord listener threw', err);
131
+ }
132
+ }
133
+ }
45
134
  }
46
135
  canUndo() {
47
136
  return this.undoStack.length > 0;
@@ -49,27 +138,45 @@ export class UndoScope {
49
138
  canRedo() {
50
139
  return this.redoStack.length > 0;
51
140
  }
52
- /** Pop the last mutator and apply its inverses. Pushes to redo. */
53
- async undo() {
54
- const entry = this.undoStack.pop();
55
- if (!entry)
56
- return;
57
- const tx = createTransaction(this.schema, this.store, this.organizationId);
58
- await applyOps(tx, entry.inverses);
59
- this.redoStack.push(entry);
60
- if (this.redoStack.length > this.maxHistory)
61
- this.redoStack.shift();
62
- }
63
- /** Pop the last undone entry and re-apply the forward ops. Pushes to undo. */
64
- async redo() {
65
- const entry = this.redoStack.pop();
66
- if (!entry)
67
- return;
68
- const tx = createTransaction(this.schema, this.store, this.organizationId);
69
- await applyOps(tx, entry.forwards);
70
- this.undoStack.push(entry);
71
- if (this.undoStack.length > this.maxHistory)
72
- this.undoStack.shift();
141
+ /**
142
+ * Pop the last mutator and apply its inverses. Pushes to redo.
143
+ *
144
+ * Under the default `skip-stale` policy the inverses are filtered against
145
+ * live state first (paired with the entry's forwards = "what I set"), so a
146
+ * field a collaborator changed after my op is left untouched — undo reverts
147
+ * my change only where it still stands.
148
+ */
149
+ undo() {
150
+ return this.enqueue(async () => {
151
+ const entry = this.undoStack.pop();
152
+ if (!entry)
153
+ return;
154
+ const tx = createTransaction(this.schema, this.store, this.organizationId);
155
+ const ops = resolveOps(entry.inverses, entry.forwards, this.store, this.conflictPolicy);
156
+ await applyOps(tx, ops);
157
+ this.redoStack.push(entry);
158
+ if (this.redoStack.length > this.maxHistory)
159
+ this.redoStack.shift();
160
+ });
161
+ }
162
+ /**
163
+ * Pop the last undone entry and re-apply the forward ops. Pushes to undo.
164
+ * Symmetric to {@link undo}: forwards are filtered against live state
165
+ * (paired with the entry's inverses = "what undo restored"), so redo
166
+ * re-asserts my change only where the undone value still stands.
167
+ */
168
+ redo() {
169
+ return this.enqueue(async () => {
170
+ const entry = this.redoStack.pop();
171
+ if (!entry)
172
+ return;
173
+ const tx = createTransaction(this.schema, this.store, this.organizationId);
174
+ const ops = resolveOps(entry.forwards, entry.inverses, this.store, this.conflictPolicy);
175
+ await applyOps(tx, ops);
176
+ this.undoStack.push(entry);
177
+ if (this.undoStack.length > this.maxHistory)
178
+ this.undoStack.shift();
179
+ });
73
180
  }
74
181
  /** Drop all history. Use after bootstrap / sync group change / sync error. */
75
182
  clear() {