@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.
- package/CHANGELOG.md +72 -1
- package/README.md +80 -66
- package/dist/BaseSyncedStore.d.ts +73 -0
- package/dist/BaseSyncedStore.js +179 -5
- package/dist/Model.d.ts +42 -0
- package/dist/Model.js +103 -44
- package/dist/SyncEngineContext.d.ts +2 -1
- package/dist/SyncEngineContext.js +5 -3
- package/dist/agent/session.js +6 -5
- package/dist/ai-sdk/coordination-context.js +4 -0
- package/dist/ai-sdk/index.d.ts +56 -47
- package/dist/ai-sdk/index.js +56 -47
- package/dist/ai-sdk/intent-broadcast.d.ts +5 -0
- package/dist/ai-sdk/intent-broadcast.js +11 -4
- package/dist/ai-sdk/wrap.d.ts +14 -11
- package/dist/ai-sdk/wrap.js +11 -13
- package/dist/auth/credentialSource.d.ts +34 -0
- package/dist/auth/credentialSource.js +63 -0
- package/dist/auth/index.d.ts +2 -22
- package/dist/auth/index.js +26 -36
- package/dist/auth/schemas.d.ts +35 -0
- package/dist/auth/schemas.js +53 -0
- package/dist/client/Ablo.d.ts +259 -33
- package/dist/client/Ablo.js +276 -73
- package/dist/client/ApiClient.d.ts +52 -4
- package/dist/client/ApiClient.js +236 -66
- package/dist/client/auth.d.ts +21 -2
- package/dist/client/auth.js +77 -5
- package/dist/client/createInternalComponents.d.ts +2 -0
- package/dist/client/createInternalComponents.js +8 -1
- package/dist/client/createModelProxy.d.ts +187 -79
- package/dist/client/createModelProxy.js +203 -68
- package/dist/client/httpClient.d.ts +71 -0
- package/dist/client/httpClient.js +69 -0
- package/dist/client/identity.d.ts +2 -6
- package/dist/client/identity.js +63 -11
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/client/registerDataSource.d.ts +19 -0
- package/dist/client/registerDataSource.js +59 -0
- package/dist/client/validateAbloOptions.d.ts +2 -1
- package/dist/client/validateAbloOptions.js +8 -7
- package/dist/core/DatabaseManager.js +30 -2
- package/dist/core/openIDBWithTimeout.d.ts +36 -0
- package/dist/core/openIDBWithTimeout.js +88 -1
- package/dist/errorCodes.d.ts +92 -1
- package/dist/errorCodes.js +139 -7
- package/dist/errors.d.ts +54 -3
- package/dist/errors.js +192 -44
- package/dist/index.d.ts +23 -10
- package/dist/index.js +21 -8
- package/dist/keys/index.d.ts +76 -0
- package/dist/keys/index.js +171 -0
- package/dist/mutators/UndoManager.d.ts +86 -50
- package/dist/mutators/UndoManager.js +129 -22
- package/dist/mutators/inverseOp.d.ts +129 -0
- package/dist/mutators/inverseOp.js +74 -0
- package/dist/mutators/readerActions.d.ts +1 -1
- package/dist/mutators/undoApply.d.ts +42 -0
- package/dist/mutators/undoApply.js +143 -0
- package/dist/query/client.d.ts +10 -9
- package/dist/query/client.js +22 -14
- package/dist/react/AbloProvider.d.ts +23 -101
- package/dist/react/AbloProvider.js +61 -103
- package/dist/react/ClientSideSuspense.d.ts +1 -1
- package/dist/react/DefaultFallback.d.ts +1 -1
- package/dist/react/SyncGroupProvider.d.ts +1 -1
- package/dist/react/index.d.ts +3 -2
- package/dist/react/index.js +3 -2
- package/dist/react/useAblo.d.ts +4 -4
- package/dist/react/useAblo.js +10 -5
- package/dist/react/useCurrentUserId.d.ts +1 -1
- package/dist/react/useCurrentUserId.js +1 -1
- package/dist/react/useMutators.js +19 -12
- package/dist/react/useReactive.js +16 -3
- package/dist/schema/ddl.d.ts +26 -3
- package/dist/schema/ddl.js +152 -4
- package/dist/schema/index.d.ts +4 -0
- package/dist/schema/index.js +12 -0
- package/dist/schema/model.d.ts +11 -0
- package/dist/schema/model.js +2 -0
- package/dist/schema/openapi.d.ts +28 -0
- package/dist/schema/openapi.js +118 -0
- package/dist/schema/plane.d.ts +23 -0
- package/dist/schema/plane.js +19 -0
- package/dist/schema/relation.d.ts +20 -0
- package/dist/schema/serialize.d.ts +7 -3
- package/dist/schema/serialize.js +6 -2
- package/dist/schema/sync-delta-row.d.ts +157 -0
- package/dist/schema/sync-delta-row.js +102 -0
- package/dist/schema/sync-delta-wire.d.ts +180 -0
- package/dist/schema/sync-delta-wire.js +102 -0
- package/dist/server/adapter.d.ts +156 -0
- package/dist/server/adapter.js +19 -0
- package/dist/server/commit.d.ts +82 -0
- package/dist/server/commit.js +1 -0
- package/dist/server/index.d.ts +14 -0
- package/dist/server/index.js +1 -0
- package/dist/server/next.d.ts +51 -0
- package/dist/server/next.js +47 -0
- package/dist/server/read-config.d.ts +60 -0
- package/dist/server/read-config.js +8 -0
- package/dist/server/storage-mode.d.ts +17 -0
- package/dist/server/storage-mode.js +12 -0
- package/dist/source/adapter.d.ts +59 -0
- package/dist/source/adapter.js +19 -0
- package/dist/source/adapters/drizzle.d.ts +34 -0
- package/dist/source/adapters/drizzle.js +147 -0
- package/dist/source/adapters/memory.d.ts +12 -0
- package/dist/source/adapters/memory.js +114 -0
- package/dist/source/adapters/prisma.d.ts +57 -0
- package/dist/source/adapters/prisma.js +199 -0
- package/dist/source/conformance.d.ts +32 -0
- package/dist/source/conformance.js +134 -0
- package/dist/source/contract.d.ts +143 -0
- package/dist/source/contract.js +98 -0
- package/dist/source/index.d.ts +61 -10
- package/dist/source/index.js +98 -0
- package/dist/source/next.d.ts +33 -0
- package/dist/source/next.js +26 -0
- package/dist/sync/BootstrapHelper.d.ts +10 -0
- package/dist/sync/BootstrapHelper.js +56 -42
- package/dist/sync/ConnectionManager.d.ts +57 -1
- package/dist/sync/ConnectionManager.js +186 -11
- package/dist/sync/HydrationCoordinator.d.ts +93 -17
- package/dist/sync/HydrationCoordinator.js +241 -41
- package/dist/sync/NetworkProbe.d.ts +60 -18
- package/dist/sync/NetworkProbe.js +121 -23
- package/dist/sync/SyncWebSocket.d.ts +45 -70
- package/dist/sync/SyncWebSocket.js +113 -89
- package/dist/sync/createIntentStream.js +10 -1
- package/dist/sync/participants.js +5 -2
- package/dist/transactions/TransactionQueue.js +13 -1
- package/dist/types/streams.d.ts +9 -0
- package/dist/utils/mobx-setup.js +1 -0
- package/dist/webhooks/events.d.ts +38 -0
- package/dist/webhooks/events.js +40 -0
- package/dist/webhooks/index.d.ts +10 -0
- package/dist/webhooks/index.js +10 -0
- package/dist/wire/errorEnvelope.d.ts +34 -0
- package/dist/wire/errorEnvelope.js +86 -0
- package/dist/wire/frames.d.ts +119 -0
- package/dist/wire/frames.js +1 -0
- package/dist/wire/index.d.ts +24 -0
- package/dist/wire/index.js +21 -0
- package/dist/wire/listEnvelope.d.ts +45 -0
- package/dist/wire/listEnvelope.js +17 -0
- package/docs/api-keys.md +5 -5
- package/docs/api.md +125 -65
- package/docs/audit.md +16 -9
- package/docs/cli.md +57 -47
- package/docs/client-behavior.md +54 -40
- package/docs/coordination.md +66 -80
- package/docs/data-sources.md +56 -34
- package/docs/examples/agent-human.md +74 -28
- package/docs/examples/ai-sdk-tool.md +29 -22
- package/docs/examples/existing-python-backend.md +41 -26
- package/docs/examples/nextjs.md +32 -17
- package/docs/examples/scoped-agent.md +43 -28
- package/docs/examples/server-agent.md +40 -15
- package/docs/guarantees.md +38 -27
- package/docs/identity.md +65 -59
- package/docs/index.md +30 -19
- package/docs/integration-guide.md +78 -78
- package/docs/interaction-model.md +43 -35
- package/docs/mcp/claude-code.md +11 -19
- package/docs/mcp/cursor.md +7 -25
- package/docs/mcp/windsurf.md +7 -20
- package/docs/mcp.md +103 -26
- package/docs/quickstart.md +63 -61
- package/docs/react.md +24 -16
- package/docs/roadmap.md +13 -13
- package/docs/schema-contract.md +111 -0
- package/docs/the-loop.md +21 -0
- package/examples/README.md +8 -4
- package/examples/data-source/README.md +10 -7
- package/examples/data-source/customer-server.ts +27 -25
- package/examples/data-source/run.ts +4 -3
- package/examples/quickstart.ts +1 -1
- package/llms.txt +55 -21
- 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.
|
|
9
|
-
* await ablo.weatherReports.update(
|
|
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
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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*
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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() {
|