@abloatai/ablo 0.28.0 → 0.29.1

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 (96) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/Database.js +9 -6
  3. package/dist/SyncClient.d.ts +2 -2
  4. package/dist/SyncClient.js +22 -10
  5. package/dist/adapters/inMemoryStorage.js +18 -14
  6. package/dist/ai-sdk/coordinatedTool.d.ts +15 -2
  7. package/dist/auth/schemas.d.ts +6 -0
  8. package/dist/auth/schemas.js +7 -0
  9. package/dist/cli.cjs +815 -400
  10. package/dist/client/Ablo.d.ts +29 -23
  11. package/dist/client/Ablo.js +8 -196
  12. package/dist/client/claimHeartbeatLoop.d.ts +1 -1
  13. package/dist/client/claimHeartbeatLoop.js +1 -1
  14. package/dist/client/createInternalComponents.d.ts +4 -2
  15. package/dist/client/createInternalComponents.js +3 -1
  16. package/dist/client/durableWrites.d.ts +21 -0
  17. package/dist/client/durableWrites.js +46 -0
  18. package/dist/client/httpClient.d.ts +19 -45
  19. package/dist/client/httpClient.js +104 -26
  20. package/dist/client/httpTransport.d.ts +8 -0
  21. package/dist/client/{ApiClient.js → httpTransport.js} +78 -291
  22. package/dist/client/options.d.ts +43 -19
  23. package/dist/client/options.js +3 -2
  24. package/dist/client/resourceTypes.d.ts +9 -85
  25. package/dist/client/resourceTypes.js +1 -1
  26. package/dist/client/sessionMint.d.ts +5 -6
  27. package/dist/client/sessionMint.js +4 -5
  28. package/dist/client/validateAbloOptions.js +3 -3
  29. package/dist/coordination/schema.d.ts +30 -0
  30. package/dist/coordination/schema.js +14 -0
  31. package/dist/core/index.d.ts +1 -1
  32. package/dist/errors.d.ts +4 -40
  33. package/dist/errors.js +5 -5
  34. package/dist/index.d.ts +14 -8
  35. package/dist/index.js +7 -5
  36. package/dist/interfaces/index.d.ts +3 -0
  37. package/dist/mutators/defineMutators.d.ts +18 -0
  38. package/dist/mutators/defineMutators.js +4 -8
  39. package/dist/react/index.d.ts +1 -1
  40. package/dist/schema/index.d.ts +1 -1
  41. package/dist/schema/index.js +1 -1
  42. package/dist/schema/select.d.ts +15 -0
  43. package/dist/schema/select.js +27 -3
  44. package/dist/source/connector.d.ts +3 -3
  45. package/dist/source/factory.d.ts +4 -6
  46. package/dist/source/factory.js +4 -7
  47. package/dist/source/index.d.ts +4 -4
  48. package/dist/source/index.js +2 -2
  49. package/dist/source/signing.d.ts +0 -3
  50. package/dist/source/types.d.ts +0 -26
  51. package/dist/stores/ObjectStore.js +8 -8
  52. package/dist/surface.d.ts +1 -1
  53. package/dist/surface.js +1 -0
  54. package/dist/sync/BootstrapFetcher.d.ts +10 -0
  55. package/dist/sync/BootstrapFetcher.js +27 -4
  56. package/dist/sync/createClaimStream.d.ts +11 -2
  57. package/dist/sync/createClaimStream.js +4 -3
  58. package/dist/testing/mocks/FakeDatabase.d.ts +1 -1
  59. package/dist/transactions/TransactionQueue.d.ts +2 -2
  60. package/dist/transactions/TransactionQueue.js +3 -3
  61. package/dist/transactions/commitEnvelope.d.ts +1 -1
  62. package/dist/transactions/commitEnvelope.js +2 -2
  63. package/dist/transactions/commitOutboxStore.d.ts +19 -19
  64. package/dist/transactions/durableWriteStore.d.ts +123 -0
  65. package/dist/transactions/durableWriteStore.js +30 -0
  66. package/dist/transactions/httpCommitEnvelope.d.ts +1 -0
  67. package/dist/transactions/httpCommitEnvelope.js +14 -12
  68. package/dist/transactions/idempotencyKey.d.ts +10 -0
  69. package/dist/transactions/idempotencyKey.js +9 -0
  70. package/dist/types/global.d.ts +10 -29
  71. package/dist/types/global.js +4 -7
  72. package/dist/types/streams.d.ts +2 -28
  73. package/dist/wire/frames.d.ts +6 -257
  74. package/dist/wire/frames.js +4 -25
  75. package/dist/wire/index.d.ts +4 -3
  76. package/dist/wire/index.js +2 -2
  77. package/dist/wire/protocolVersion.d.ts +30 -17
  78. package/dist/wire/protocolVersion.js +34 -18
  79. package/docs/agents.md +8 -3
  80. package/docs/api.md +6 -4
  81. package/docs/client-behavior.md +6 -3
  82. package/docs/coordination.md +2 -3
  83. package/docs/data-sources.md +5 -8
  84. package/docs/guarantees.md +21 -0
  85. package/docs/integration-guide.md +1 -0
  86. package/docs/migration.md +21 -1
  87. package/docs/quickstart.md +11 -0
  88. package/docs/react.md +0 -46
  89. package/examples/README.md +6 -5
  90. package/llms.txt +15 -15
  91. package/package.json +2 -7
  92. package/dist/client/ApiClient.d.ts +0 -177
  93. package/dist/commit/contract.d.ts +0 -493
  94. package/dist/commit/contract.js +0 -187
  95. package/dist/commit/index.d.ts +0 -6
  96. package/dist/commit/index.js +0 -5
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.29.1
4
+
5
+ ### Patch Changes
6
+
7
+ - **The `omitModels` doc example uses generic model names.** The schema-projection example in the `omitModels` JSDoc and the API docs now reads `omitModels(full, ['reports', 'reportSections'])` — an illustrative parent/child pair rather than application-specific model names. Documentation only; no API or behavior change.
8
+
9
+ ## 0.29.0
10
+
11
+ ### Minor Changes
12
+
13
+ - **`omitModels()` projects a schema by exclusion — the companion to `selectModels`.** Keep every model except the named ones, so one app can be the general case while a separate app owns the models it drops. The suite shell narrows with `omitModels(full, ['reports', 'reportSections'])` while the standalone app selects those same models. Validation matches `selectModels`: relations into the omitted set are dropped, and a dropped `parent` edge throws, so a model whose scope routes through an omitted parent can't be silently kept. Each app now binds its own schema projection rather than sharing one global registration.
14
+
15
+ - **Every application model has one typed access path: `ablo.<model>`.** Both the WebSocket and stateless HTTP clients return bare rows from `retrieve` and accept the same typed create/update/delete forms. The schema-less `Ablo({ schema: null })` overload, the public `.model(name)` accessors, the transport-envelope types, and the hidden capability CRUD client are removed. Select `transport: 'http'` for workers without changing model syntax, use `commits.create` only for atomic multi-row writes, and `sessions.create` to mint a scoped user or agent credential.
16
+
17
+ - **The commit wire names only the protocol that actually runs.** `@abloatai/ablo/wire` exports `CommitMessage`, `MutationResultMessage`, `commitOperationSchema`, and `commitPayloadSchema` directly. The prospective v2 commit contract published under `@abloatai/ablo/commit` in 0.28.0 — its `CommitReceipt` type, the `legacyCommit*` wire aliases, and the unsent named-mutation frame — is removed, so there is one contract to implement and test. Idempotency-key sealing and fencing move to `transactions/idempotencyKey.ts` (ADR 0006: the v1/v2 boundary lives at ingress, not in the client). Local `useMutators` calls still compose normal transactions; this removes only the dead server wire path.
18
+
19
+ - **Protocol support is backed by codecs, not a numeric claim (ADR 0005).** The wire package publishes an explicit supported-version manifest; the server must provide a decoder for every entry. Durable HTTP writes seal their original protocol version and replay with it after an upgrade. Application-schema hashes are an advisory bootstrap warning rather than an exact WebSocket gate, so additive schema versions can overlap through an expand/contract rollout.
20
+
21
+ - **The injected write-storage option is `durableWrites`, and `DurableWriteStore` is its interface.** The `commitOutbox` client option and `CommitOutboxStore` interface introduced in 0.28.0 are renamed. `durableWritesConfigSchema`, `durableWriteStoreSchema`, `pendingWriteSchema`, and the `DurableWritesConfig` / `DurableWriteStore` / `PendingWrite` types are exported from the package root, so a workflow-, SQLite-, or filesystem-backed adapter validates against the shipped schema instead of an interface you re-declare. The old `commitOutbox` / `CommitOutboxStore` names remain as deprecated aliases through the next major release.
22
+
23
+ - **`Register` registers your `Schema` and `UserMeta` — those are the two keys.** The `Presence` and `Claims` registration keys and their `ResolvePresence` / `ResolveClaims` resolver types are gone, matching the removed presence and claim React placeholders. Read presence through `usePeers` or `useWatch`, and row claims through `ablo.<model>.claim`. The default resolver now carries a `SchemaRecord` fallback, so a shared package typechecks standalone with no app registration in scope.
24
+
25
+ - **One signed adapter export: `dataSource`.** The `abloSource` alias is removed, and the parallel `DataSource*` type names (`DataSourceOptions`, `DataSourceCommitParams`, `DataSourceHandlerContext`, and the rest) collapse into the canonical `Source*` types — one name per shape. Endpoints already built with `dataSource()` are unchanged; only the duplicate names are gone.
26
+
27
+ - **The claim type surface is reached through `Ablo.Claim.*`.** `ClaimOptions`, `ClaimParams`, `ClaimLookupParams`, `ClaimReorderParams`, `Claim`, `ClaimHeartbeat`, `ClaimHeartbeatOptions`, and `ModelOperations` are namespace members rather than top-level exports; `HeldClaim` stays as a deprecated top-level alias because 0.20.1 documented it. The internal construction types `InternalAbloOptions` and `HttpClaimApi` are no longer exported — the public surface names only what an application builds against.
28
+
29
+ ### Patch Changes
30
+
31
+ - **Removed React placeholders that had no working runtime.** `usePresence`, `useClaim`, `SyncGroupProvider`, and `useSyncGroup` are no longer exported. Use `usePeers` or `useWatch` for presence and scoped participation, and `ablo.<model>.claim` for row claims. The unused `Register.Presence` / `Register.Claims` resolver types are gone with them.
32
+
3
33
  ## 0.28.0
4
34
 
5
35
  ### Minor Changes
@@ -26,6 +56,12 @@
26
56
 
27
57
  - **Docs and error copy name the real HTTP mount.** REST paths are documented as `/api/v1/...` — the OpenAPI server URLs carry the `/api` mount — and the `jwt_issuer_untrusted` message no longer points at an endpoint that doesn't exist.
28
58
 
59
+ - **The resume cursor advances only through the contiguous durable prefix of a delta frame.** A frame spanning several local stores commits per store, and those transactions can fail independently — taking the maximum id from any successful store could permanently skip an earlier failed delta on every later catch-up.
60
+
61
+ - **Conflicting undo-scope options warn instead of being silently ignored.** A scope keeps the options it was created with; the silent version of that rule could leave a surface believing it enabled stream recording when it hadn't.
62
+
63
+ - **Docs and error copy name the real HTTP mount.** REST paths are documented as `/api/v1/...` — the OpenAPI server URLs carry the `/api` mount — and the `jwt_issuer_untrusted` message no longer points at an endpoint that doesn't exist.
64
+
29
65
  ## 0.27.0
30
66
 
31
67
  ### Minor Changes
package/dist/Database.js CHANGED
@@ -28,6 +28,9 @@ function isSameOutboxRecord(existing, candidate) {
28
28
  type: record.type,
29
29
  storageVersion: record.storageVersion,
30
30
  idempotencyKey: record.idempotencyKey,
31
+ // HTTP outbox rows written before protocol versioning are v1. Normalize
32
+ // them so a same-request re-seal remains idempotent after an upgrade.
33
+ protocolVersion: record.protocolVersion ?? 1,
31
34
  request: record.request,
32
35
  scopeNamespace: record.scopeNamespace,
33
36
  });
@@ -1259,14 +1262,14 @@ export class Database {
1259
1262
  const store = this.transactionStore;
1260
1263
  const existing = (await store.get(recordId));
1261
1264
  if (existing && !isSameOutboxRecord(existing, record)) {
1262
- throw new AbloValidationError('Commit outbox key already identifies a different request', {
1265
+ throw new AbloValidationError('Pending-write key already identifies a different request', {
1263
1266
  code: 'idempotency_conflict',
1264
1267
  });
1265
1268
  }
1266
1269
  if (!existing) {
1267
1270
  const sources = await Promise.all(consumedRecordIds.map((id) => store.get(id)));
1268
1271
  if (sources.some((source) => source === undefined)) {
1269
- throw new AbloValidationError('Commit outbox source mutations were already claimed by another envelope', { code: 'idempotency_conflict' });
1272
+ throw new AbloValidationError('Pending-write source mutations were already claimed by another write', { code: 'idempotency_conflict' });
1270
1273
  }
1271
1274
  await store.add(record);
1272
1275
  }
@@ -1278,7 +1281,7 @@ export class Database {
1278
1281
  }
1279
1282
  const db = this.workspaceDb;
1280
1283
  if (!db || this.isClosing) {
1281
- throw new AbloConnectionError('Database not opened for commit outbox', {
1284
+ throw new AbloConnectionError('Database not opened for durable writes', {
1282
1285
  code: 'db_not_opened',
1283
1286
  });
1284
1287
  }
@@ -1304,7 +1307,7 @@ export class Database {
1304
1307
  return;
1305
1308
  promotionStarted = true;
1306
1309
  if (existing && !isSameOutboxRecord(existing, record)) {
1307
- collisionError = new AbloValidationError('Commit outbox key already identifies a different request', { code: 'idempotency_conflict' });
1310
+ collisionError = new AbloValidationError('Pending-write key already identifies a different request', { code: 'idempotency_conflict' });
1308
1311
  tx.abort();
1309
1312
  return;
1310
1313
  }
@@ -1314,7 +1317,7 @@ export class Database {
1314
1317
  // cannot dispatch. An identical existing envelope is an idempotent
1315
1318
  // retry, so its already-consumed sources may be absent.
1316
1319
  if (!existing && sourceExists.some((exists) => !exists)) {
1317
- collisionError = new AbloValidationError('Commit outbox source mutations were already claimed by another envelope', { code: 'idempotency_conflict' });
1320
+ collisionError = new AbloValidationError('Pending-write source mutations were already claimed by another write', { code: 'idempotency_conflict' });
1318
1321
  tx.abort();
1319
1322
  return;
1320
1323
  }
@@ -1346,7 +1349,7 @@ export class Database {
1346
1349
  reject(collisionError ??
1347
1350
  tx.error ??
1348
1351
  getRequest.error ??
1349
- new Error('Commit outbox transaction aborted'));
1352
+ new Error('Durable-write transaction aborted'));
1350
1353
  };
1351
1354
  tx.onerror = () => {
1352
1355
  // onabort owns rejection so the promise settles exactly once.
@@ -15,7 +15,7 @@ import { type UnconfirmedWritesMetrics } from './transactions/UnconfirmedWrites.
15
15
  import type { Database } from './Database.js';
16
16
  import type { WriteOptions } from './interfaces/index.js';
17
17
  import { SyncPosition } from './sync/syncPosition.js';
18
- import { type CommitOutboxStore } from './transactions/commitOutboxStore.js';
18
+ import type { DurableWriteStore } from './transactions/durableWriteStore.js';
19
19
  interface SyncObserver {
20
20
  onSync?: (event: SyncEvent) => void;
21
21
  }
@@ -76,7 +76,7 @@ export declare class SyncClient extends EventEmitter {
76
76
  * responses, and snapshots and claims read `readFloor`.
77
77
  */
78
78
  readonly position: SyncPosition;
79
- constructor(objectPool: InstanceCache, database: Database, commitOutbox?: CommitOutboxStore, commitOutboxNamespace?: string);
79
+ constructor(objectPool: InstanceCache, database: Database, commitOutbox?: DurableWriteStore, commitOutboxNamespace?: string);
80
80
  /**
81
81
  * Setup network monitoring handlers
82
82
  */
@@ -1351,24 +1351,36 @@ export class SyncClient extends EventEmitter {
1351
1351
  // never dispatch without its durable record), so drop it rather than
1352
1352
  // leaving it queued to poison every later pass. Healthy batch members
1353
1353
  // still stage.
1354
- const journalResults = await Promise.allSettled(mutations.map((mutation) => mutation.journaled));
1354
+ // Settle each mutation into an object that keeps the mutation bound to its
1355
+ // own outcome. Correlating by array index would force a `mutations[index]!`
1356
+ // non-null assertion (the lookup is `T | undefined` under
1357
+ // noUncheckedIndexedAccess); carrying the reference makes it unnecessary.
1358
+ const journalOutcomes = await Promise.all(mutations.map(async (mutation) => {
1359
+ try {
1360
+ await mutation.journaled;
1361
+ return { mutation, ok: true };
1362
+ }
1363
+ catch (reason) {
1364
+ return { mutation, ok: false, reason };
1365
+ }
1366
+ }));
1355
1367
  const journaledMutations = [];
1356
- journalResults.forEach((result, index) => {
1357
- const mutation = mutations[index];
1358
- if (result.status === 'fulfilled') {
1368
+ for (const outcome of journalOutcomes) {
1369
+ const { mutation } = outcome;
1370
+ if (outcome.ok) {
1359
1371
  journaledMutations.push(mutation);
1360
- return;
1372
+ continue;
1361
1373
  }
1362
1374
  this.stagedMutationIds.delete(mutation.mutationId);
1363
1375
  this.pendingMutations = this.pendingMutations.filter((pending) => pending.mutationId !== mutation.mutationId);
1364
- mutation.rejectStaged?.(result.reason);
1376
+ mutation.rejectStaged?.(outcome.reason);
1365
1377
  getContext().observability.captureTransactionFailure({
1366
1378
  context: 'persist-pending-mutation',
1367
- error: result.reason instanceof Error
1368
- ? result.reason
1369
- : new Error(String(result.reason)),
1379
+ error: outcome.reason instanceof Error
1380
+ ? outcome.reason
1381
+ : new Error(String(outcome.reason)),
1370
1382
  });
1371
- });
1383
+ }
1372
1384
  // Stage every mutation synchronously within the same event-loop tick;
1373
1385
  // the transaction queue's microtask batches and sends them together.
1374
1386
  for (const mutation of journaledMutations) {
@@ -22,7 +22,7 @@ export class InMemoryObjectStore {
22
22
  this.indexes.set(name, new Map());
23
23
  }
24
24
  }
25
- async add(record) {
25
+ add(record) {
26
26
  const id = record.id;
27
27
  if (!id)
28
28
  throw new TypeError('In-memory record must carry an id');
@@ -33,11 +33,12 @@ export class InMemoryObjectStore {
33
33
  }
34
34
  this.data.set(id, { ...record });
35
35
  this.addToIndexes(id, record);
36
+ return Promise.resolve();
36
37
  }
37
- async put(record) {
38
+ put(record) {
38
39
  const id = record.id;
39
40
  if (!id)
40
- return;
41
+ return Promise.resolve();
41
42
  // Remove from old index entries if updating
42
43
  const existing = this.data.get(id);
43
44
  if (existing) {
@@ -45,24 +46,26 @@ export class InMemoryObjectStore {
45
46
  }
46
47
  this.data.set(id, { ...record });
47
48
  this.addToIndexes(id, record);
49
+ return Promise.resolve();
48
50
  }
49
- async get(id) {
50
- return this.data.get(id);
51
+ get(id) {
52
+ return Promise.resolve(this.data.get(id));
51
53
  }
52
- async getAll() {
53
- return [...this.data.values()];
54
+ getAll() {
55
+ return Promise.resolve([...this.data.values()]);
54
56
  }
55
- async delete(id) {
57
+ delete(id) {
56
58
  const existing = this.data.get(id);
57
59
  if (existing) {
58
60
  this.removeFromIndexes(id, existing);
59
61
  this.data.delete(id);
60
62
  }
63
+ return Promise.resolve();
61
64
  }
62
- async getAllFromIndex(indexName, value) {
65
+ getAllFromIndex(indexName, value) {
63
66
  const index = this.indexes.get(indexName);
64
67
  if (!index)
65
- return [];
68
+ return Promise.resolve([]);
66
69
  // The in-memory index stores values as strings (it doesn't support
67
70
  // the full IDB key range — Date / BufferSource / arrays). For the
68
71
  // overwhelmingly-common case of string FK ids, coercing through
@@ -70,16 +73,17 @@ export class InMemoryObjectStore {
70
73
  // shared `ObjectStoreContract` signature.
71
74
  const ids = index.get(String(value));
72
75
  if (!ids)
73
- return [];
74
- return [...ids]
76
+ return Promise.resolve([]);
77
+ return Promise.resolve([...ids]
75
78
  .map((id) => this.data.get(id))
76
- .filter((r) => r != null);
79
+ .filter((r) => r != null));
77
80
  }
78
- async clear() {
81
+ clear() {
79
82
  this.data.clear();
80
83
  for (const index of this.indexes.values()) {
81
84
  index.clear();
82
85
  }
86
+ return Promise.resolve();
83
87
  }
84
88
  /** No-op — in-memory stores don't need closing. */
85
89
  markAsClosing() { }
@@ -58,7 +58,9 @@
58
58
  * first-in-first-out, which would require a persistent connection.
59
59
  */
60
60
  import type { z } from 'zod';
61
- import type { ModelOperations } from '../client/createModelProxy.js';
61
+ import type { ClaimParams, ModelRetrieveParams, ModelUpdateParams } from '../client/createModelProxy.js';
62
+ import type { ModelUpdater, ContentionOptions } from '../client/functionalUpdate.js';
63
+ import type { HeldClaim } from '../types/streams.js';
62
64
  export type CoordinationStrategy = 'merge' | 'claim' | 'queue';
63
65
  /** The structured result the tool hands back to the model (or the caller). */
64
66
  export interface CoordinatedWriteResult<T> {
@@ -105,4 +107,15 @@ export interface CoordinatedToolOptions<TInput, T> {
105
107
  timeoutMs?: number;
106
108
  };
107
109
  }
108
- export declare function coordinatedTool<TInput, T = Record<string, unknown>, CreateInput = Partial<T>>(model: ModelOperations<T, CreateInput>, options: CoordinatedToolOptions<TInput, T>): import("ai").Tool<TInput, CoordinatedWriteResult<T>>;
110
+ /**
111
+ * The small model port this helper needs. Both typed transports implement this
112
+ * exact contract; local-cache methods, wire receipts, and transport lifecycle
113
+ * deliberately stay out of an AI tool's dependency surface.
114
+ */
115
+ export interface CoordinatedModel<T> {
116
+ retrieve(params: ModelRetrieveParams): Promise<T | undefined>;
117
+ update(params: ModelUpdateParams<T>): Promise<T>;
118
+ update(id: string, updater: ModelUpdater<T>, options?: ContentionOptions): Promise<T | undefined>;
119
+ claim(params: ClaimParams<T>): Promise<HeldClaim<T>>;
120
+ }
121
+ export declare function coordinatedTool<TInput, T = Record<string, unknown>>(model: CoordinatedModel<T>, options: CoordinatedToolOptions<TInput, T>): import("ai").Tool<TInput, CoordinatedWriteResult<T>>;
@@ -27,6 +27,12 @@ export declare const IdentityResolveResponseSchema: z.ZodObject<{
27
27
  }>;
28
28
  participantId: z.ZodString;
29
29
  accountScope: z.ZodString;
30
+ projectId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
31
+ environment: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
32
+ production: "production";
33
+ sandbox: "sandbox";
34
+ }>>>;
35
+ sandboxId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
30
36
  syncGroups: z.ZodArray<z.ZodString>;
31
37
  userMeta: z.ZodRecord<z.ZodString, z.ZodUnknown>;
32
38
  }, z.core.$loose>;
@@ -25,6 +25,13 @@ export const IdentityResolveResponseSchema = z
25
25
  participantKind: AuthParticipantKindSchema,
26
26
  participantId: z.string().min(1),
27
27
  accountScope: z.string().min(1),
28
+ // The rest of the plane this credential resolves to. `nullish` (optional +
29
+ // nullable) so a server too old to send them still parses, and a human
30
+ // session — which carries no such scope — validates with them absent.
31
+ // `projectId` equals the org id for the org-default project.
32
+ projectId: z.string().min(1).nullish(),
33
+ environment: z.enum(['sandbox', 'production']).nullish(),
34
+ sandboxId: z.string().min(1).nullish(),
28
35
  syncGroups: z.array(z.string()),
29
36
  userMeta: z.record(z.string(), z.unknown()),
30
37
  })