@abloatai/ablo 0.29.3 → 0.30.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 (118) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +3 -2
  3. package/dist/BaseSyncedStore.js +3 -1
  4. package/dist/Database.js +29 -2
  5. package/dist/SyncClient.d.ts +5 -3
  6. package/dist/SyncClient.js +8 -4
  7. package/dist/ai-sdk/coordinatedTool.d.ts +0 -1
  8. package/dist/ai-sdk/coordinatedTool.js +0 -1
  9. package/dist/ai-sdk/coordinationContext.js +1 -5
  10. package/dist/cli.cjs +377 -84
  11. package/dist/client/Ablo.d.ts +1 -1
  12. package/dist/client/Ablo.js +24 -36
  13. package/dist/client/auth.d.ts +8 -18
  14. package/dist/client/auth.js +16 -96
  15. package/dist/client/createModelProxy.d.ts +16 -11
  16. package/dist/client/createModelProxy.js +162 -20
  17. package/dist/client/httpClient.d.ts +1 -1
  18. package/dist/client/httpTransport.js +263 -89
  19. package/dist/client/options.d.ts +0 -15
  20. package/dist/client/resourceTypes.d.ts +13 -26
  21. package/dist/client/schemaConfig.js +5 -0
  22. package/dist/client/writeOptionsSchema.d.ts +1 -0
  23. package/dist/client/writeOptionsSchema.js +11 -2
  24. package/dist/client/wsMutationExecutor.d.ts +3 -5
  25. package/dist/coordination/schema.d.ts +17 -13
  26. package/dist/coordination/schema.js +36 -10
  27. package/dist/errorCodes.d.ts +9 -4
  28. package/dist/errorCodes.js +10 -5
  29. package/dist/errors.js +4 -9
  30. package/dist/index.d.ts +4 -0
  31. package/dist/interfaces/index.d.ts +31 -19
  32. package/dist/schema/index.d.ts +1 -1
  33. package/dist/schema/index.js +4 -1
  34. package/dist/schema/schema.d.ts +13 -0
  35. package/dist/schema/select.js +7 -0
  36. package/dist/schema/syncDeltaRow.d.ts +31 -13
  37. package/dist/schema/syncDeltaRow.js +33 -15
  38. package/dist/schema/tenancy.d.ts +52 -16
  39. package/dist/schema/tenancy.js +86 -25
  40. package/dist/server/commit.d.ts +8 -24
  41. package/dist/server/index.d.ts +1 -0
  42. package/dist/server/index.js +1 -0
  43. package/dist/server/readConfig.d.ts +10 -0
  44. package/dist/source/adapter.d.ts +26 -15
  45. package/dist/source/adapter.js +5 -3
  46. package/dist/source/adapters/drizzle.js +35 -9
  47. package/dist/source/adapters/kysely.d.ts +33 -61
  48. package/dist/source/adapters/kysely.js +170 -177
  49. package/dist/source/adapters/kyselyMutationCore.d.ts +76 -0
  50. package/dist/source/adapters/kyselyMutationCore.js +125 -0
  51. package/dist/source/adapters/memory.d.ts +1 -1
  52. package/dist/source/adapters/memory.js +24 -10
  53. package/dist/source/adapters/prisma.js +35 -8
  54. package/dist/source/conformance.d.ts +26 -28
  55. package/dist/source/conformance.js +142 -69
  56. package/dist/source/contract.d.ts +46 -4
  57. package/dist/source/contract.js +66 -5
  58. package/dist/source/factory.d.ts +3 -3
  59. package/dist/source/factory.js +24 -3
  60. package/dist/source/idempotency.d.ts +46 -0
  61. package/dist/source/idempotency.js +129 -0
  62. package/dist/source/index.d.ts +6 -4
  63. package/dist/source/index.js +5 -3
  64. package/dist/source/migrations.d.ts +9 -5
  65. package/dist/source/migrations.js +39 -6
  66. package/dist/source/types.d.ts +88 -14
  67. package/dist/source/types.js +16 -0
  68. package/dist/surface.d.ts +1 -1
  69. package/dist/surface.js +0 -1
  70. package/dist/sync/BootstrapFetcher.js +7 -0
  71. package/dist/sync/SyncWebSocket.d.ts +5 -27
  72. package/dist/sync/awaitClaimGrant.d.ts +6 -0
  73. package/dist/sync/awaitClaimGrant.js +12 -2
  74. package/dist/sync/commitFrames.d.ts +2 -4
  75. package/dist/sync/commitFrames.js +1 -0
  76. package/dist/sync/createClaimStream.d.ts +1 -1
  77. package/dist/sync/createClaimStream.js +13 -15
  78. package/dist/sync/deltaPipeline.d.ts +1 -1
  79. package/dist/sync/deltaPipeline.js +1 -1
  80. package/dist/sync/participants.d.ts +2 -3
  81. package/dist/sync/participants.js +2 -2
  82. package/dist/sync/wsFrameHandlers.js +35 -15
  83. package/dist/testing/mocks/MockMutationExecutor.d.ts +21 -0
  84. package/dist/testing/mocks/MockMutationExecutor.js +53 -1
  85. package/dist/transactions/TransactionQueue.d.ts +61 -9
  86. package/dist/transactions/TransactionQueue.js +502 -114
  87. package/dist/transactions/commitEnvelope.d.ts +4 -0
  88. package/dist/transactions/commitEnvelope.js +25 -0
  89. package/dist/transactions/commitPayload.d.ts +23 -5
  90. package/dist/transactions/commitPayload.js +3 -0
  91. package/dist/transactions/deltaConfirmation.d.ts +2 -1
  92. package/dist/transactions/deltaConfirmation.js +22 -4
  93. package/dist/transactions/durableWriteStore.d.ts +8 -1
  94. package/dist/transactions/httpCommitEnvelope.d.ts +3 -1
  95. package/dist/transactions/httpCommitEnvelope.js +30 -0
  96. package/dist/transactions/replayValidation.d.ts +4 -0
  97. package/dist/transactions/replayValidation.js +6 -0
  98. package/dist/types/streams.d.ts +29 -17
  99. package/dist/wire/commit.d.ts +671 -0
  100. package/dist/wire/commit.js +300 -0
  101. package/dist/wire/delta.d.ts +4 -0
  102. package/dist/wire/delta.js +9 -0
  103. package/dist/wire/frames.d.ts +17 -45
  104. package/dist/wire/index.d.ts +2 -0
  105. package/dist/wire/index.js +4 -0
  106. package/docs/agent-messaging.md +4 -5
  107. package/docs/agents.md +1 -2
  108. package/docs/api-keys.md +1 -2
  109. package/docs/client-behavior.md +4 -6
  110. package/docs/coordination.md +64 -9
  111. package/docs/data-sources.md +4 -7
  112. package/docs/integration-guide.md +6 -8
  113. package/docs/migration.md +0 -3
  114. package/docs/quickstart.md +7 -8
  115. package/docs/schema-contract.md +7 -8
  116. package/package.json +4 -4
  117. package/dist/client/registerDataSource.d.ts +0 -19
  118. package/dist/client/registerDataSource.js +0 -66
package/dist/cli.cjs CHANGED
@@ -276809,15 +276809,12 @@ var ERROR_CODES = {
276809
276809
  jwt_org_membership_denied: wire("auth", 403, false, "The bearer JWT's subject is not an active member of the organization in its `org_id` claim (removed, suspended, or the claim does not match a membership)."),
276810
276810
  file_upload_auth_required: wire("auth", 401, false, "File uploads require an authenticated session. Sign in and retry."),
276811
276811
  browser_apikey_blocked: client("auth", "A raw API key was used from a browser, where anyone can read it. Keep secret keys server-side and hand the browser a short-lived ephemeral key instead."),
276812
- browser_database_url_blocked: client("auth", "A database connection string was used from a browser context. It carries database credentials, so it must stay server-side."),
276813
- datasource_registration_failed: client("auth", "The provided `databaseUrl` could not be registered as a data source. Check that the connection string is valid and the database is reachable."),
276814
276812
  datasource_connection_unsupported: wire("validation", 400, false, "This deployment does not accept direct connection-string data sources. Register a signed Data Source endpoint instead."),
276815
- datasource_direct_deprecated: wire("validation", 410, false, "The direct (connection string) datasource is deprecated. Register a signed Data Source endpoint instead \u2014 your app owns the write and your credentials never leave it."),
276816
276813
  // ── permission / capability (403) ──────────────────────────────────
276817
276814
  capability_scope_denied: wire("capability", 403, false, "This action falls outside the scope granted to the connection, so it was denied."),
276818
276815
  issuer_register_forbidden: wire("permission", 403, false, "Registering a trusted issuer requires a secret (`sk_`) API key. The key presented is not a secret key."),
276819
276816
  capability_invalid: wire("capability", 403, false, "This capability cannot be used \u2014 it is unknown, revoked, or expired. Request a fresh grant."),
276820
- test_database_not_registered: wire("permission", 403, false, "Test mode requires a registered dev database for this org \u2014 run `npx ablo init`, or construct the client with `databaseUrl` using your test key."),
276817
+ test_database_not_registered: wire("permission", 403, false, "Test mode requires a registered dev database for this org \u2014 run `npx ablo init` to register one for your test key."),
276821
276818
  tenant_routing_failed: wire("server", 500, true, "The org's registered database could not be resolved or dialed. Ablo never falls back to shared storage for a dedicated tenant \u2014 retry, and check the datasource status if it persists."),
276822
276819
  database_role_cannot_enforce_rls: wire("permission", 403, false, "The database role Ablo connects with is a superuser or has `BYPASSRLS`, so Postgres will not enforce row-level security for it. Connect with a role that is subject to RLS."),
276823
276820
  database_role_unreadable: wire("permission", 403, false, "Ablo could not introspect the database role it connects with, so it cannot verify that row-level security is enforced."),
@@ -276837,6 +276834,7 @@ var ERROR_CODES = {
276837
276834
  // behind the holder (`ablo.<model>.claim`), or re-read and rebase.
276838
276835
  claim_conflict: wire("claim", 409, false, "Another participant holds a claim on this row, so the write was rejected. Take a claim with `ablo.<model>.claim` to queue fairly behind the holder, or re-read and rebase."),
276839
276836
  claim_lost: wire("claim", 409, false, "The claim held on this row was lost before the write could apply. Re-acquire the claim and retry."),
276837
+ fence_token_stale: wire("claim", 409, false, "This write carried a fencing token below the row\u2019s current high-water: a later holder claimed the row, wrote, and moved on while this claim was lapsed, so applying the write would silently overwrite their work. The claim is gone \u2014 re-claim the row and retry from the current state."),
276840
276838
  entity_claimed: wire("claim", 409, false, "This row is currently claimed by another participant, so the write was blocked. Queue behind the holder with `ablo.<model>.claim`, or wait for the claim to clear."),
276841
276839
  malformed_claim: wire("claim", 400, false, "The claim payload could not be parsed. A claim must name the model and the entity it targets; check the payload shape and resend."),
276842
276840
  malformed_subscription: wire("validation", 400, false, "The `update_subscription` payload was malformed; expected `{ syncGroups: string[] }`."),
@@ -276857,6 +276855,8 @@ var ERROR_CODES = {
276857
276855
  contention_exhausted: client("conflict", "A functional update kept losing to concurrent writes and exhausted its reconcile budget. Back off and retry, raise `retries`, or move the row to the WebSocket transport."),
276858
276856
  update_aborted: client("conflict", "The functional update was aborted via its `AbortSignal` before the write landed; nothing was written."),
276859
276857
  idempotency_conflict: wire("conflict", 409, false, "This `Idempotency-Key` was already used with a different request body. Reuse a key only to retry an identical request; otherwise generate a new one."),
276858
+ idempotency_key_expired: wire("conflict", 409, false, "This idempotency key belongs to an expired retained source intent and cannot be executed again safely. Use a new key only for a genuinely new write."),
276859
+ source_transport_pinned: wire("conflict", 409, false, "An earlier attempt under this idempotency key was pinned to a different source transport. Restore that route and retry the same key; Ablo will not switch a possibly committed write."),
276860
276860
  idempotency_key_too_long: wire("validation", 400, false, "The supplied `Idempotency-Key` exceeds the maximum length. Use a shorter key \u2014 a UUID works well."),
276861
276861
  // ── validation (400 / 422) ─────────────────────────────────────────
276862
276862
  write_options_invalid: client("validation", "The write options (`idempotencyKey` / `label` / `wait` / `readAt` / `onStale` / `claim`) failed validation against the write-options schema."),
@@ -276946,9 +276946,12 @@ var ERROR_CODES = {
276946
276946
  exchange_malformed_response: wire("transport", 502, true, "The credential exchange returned a response that could not be parsed. Retrying may succeed."),
276947
276947
  exchange_network_error: wire("transport", 503, true, "A network error interrupted the credential exchange. Check connectivity and retry."),
276948
276948
  source_network_error: wire("transport", 503, true, "A network error occurred while talking to the data source. Check connectivity and retry."),
276949
+ source_unreachable: wire("transport", 503, true, "Ablo could not safely reach the registered direct data source before completing the write. The write remains pinned to direct; retry the same idempotency key after connectivity recovers."),
276950
+ data_source_blocked: wire("transport", 503, false, "The plane's data source is not in a verified, resolvable state. Fix its registration, secret, or configuration; Ablo will not fall through to hosted storage."),
276949
276951
  identity_network_error: wire("transport", 503, true, "A network error occurred while resolving your identity. Check connectivity and retry."),
276950
276952
  commit_no_result: wire("transport", 504, true, "The commit was sent, but no result frame arrived, so its outcome is unknown. It is safe to retry."),
276951
276953
  commit_failed: wire("transport", 500, true, "The commit reached the server but failed to apply. Retrying may succeed."),
276954
+ replication_lag_timeout: wire("transport", 504, true, "The data source accepted the write, but its correlated authoritative source delta did not arrive before the confirmation deadline. The write may still materialize; retry with the same idempotency key or wait for source ingestion to recover."),
276952
276955
  commit_offline_grace_expired: wire("transport", 503, false, "The offline grace window expired before this commit could be sent, so it was not applied. Re-apply the change once the connection returns."),
276953
276956
  queue_too_deep: wire("transport", 503, true, "The transaction queue is over its depth limit, so new writes are being rejected until it drains. Retry shortly."),
276954
276957
  flush_timeout: wire("transport", 504, true, "Flushing the transaction queue timed out before every pending write was sent. Retry once connectivity stabilizes."),
@@ -277030,6 +277033,8 @@ var ERROR_CODES = {
277030
277033
  invalid_id: wire("validation", 400, false, "The id in the request is not a valid identifier."),
277031
277034
  unknown_model: wire("tenant", 400, false, "Named a model the server does not know. Run `ablo push` (or keep `ablo dev` running) to upload `ablo/schema.ts` \u2014 the server keeps its own copy of the schema."),
277032
277035
  model_not_tenant_scoped: wire("tenant", 400, false, "This model is not tenant-scoped, so it cannot be queried through the tenant-scoped read path."),
277036
+ source_tenancy_not_enforced: wire("tenant", 400, false, "This model is scoped by its connected data source (`policy: { by: 'source' }`), so its tenant is resolved from the source registration rather than a row column. Enforcing that resolution requires the write-through connect path, which is not enabled on this plane yet \u2014 so the model cannot be served through the tenant-scoped read or bootstrap path without risking a cross-tenant read. If this model lives on a log plane, scope it with `by: 'column'` or `by: 'parent'` instead."),
277037
+ model_not_provisioned: wire("tenant", 409, false, "This model is in the plane's registered schema, but its table has not been provisioned yet. Registering a schema with `ablo push` records the model; a plane's physical tables are created separately, out of band, so a model can appear in the schema before its table exists. Provision the plane's tables, then retry the read."),
277033
277038
  schema_table_invalid: wire("schema", 500, false, "The model's table identifier is invalid."),
277034
277039
  schema_scope_invalid: wire("schema", 500, false, "The model's scope predicate could not be built."),
277035
277040
  entity_fetch_failed: wire("server", 500, true, "The server failed to fetch the requested entity. It is safe to retry."),
@@ -277219,17 +277224,30 @@ var claimStatusSchema = import_zod3.z.enum([
277219
277224
  ]);
277220
277225
  var wireClaimBaseSchema = targetRefSchema.extend({
277221
277226
  claimId: import_zod3.z.string(),
277222
- /** Human-readable phase: 'editing' | 'reviewing' | 'forecasting' … */
277223
- reason: import_zod3.z.string(),
277227
+ /**
277228
+ * Peer-visible description of the work being done (`'rewriting the risk
277229
+ * section to match Q3'`). The server stamps a default when a frame carries
277230
+ * none.
277231
+ */
277232
+ description: import_zod3.z.string().optional(),
277224
277233
  /** Server-stamped declaration time (epoch ms). */
277225
277234
  declaredAt: import_zod3.z.number(),
277226
277235
  /** Server-computed TTL deadline (epoch ms). Readers treat as advisory. */
277227
277236
  expiresAt: import_zod3.z.number(),
277228
- status: claimStatusSchema.optional()
277237
+ status: claimStatusSchema.optional(),
277238
+ /**
277239
+ * The monotonic fencing token the server minted for this grant (Option B).
277240
+ * Strictly increasing per entity across successive grants, so a write that
277241
+ * carries it can be rejected at commit if a later holder already advanced the
277242
+ * entity's high-water. Server-stamped on grant and never client-supplied;
277243
+ * optional so every existing frame stays valid and a token-less write is
277244
+ * simply not fence-checked.
277245
+ */
277246
+ fenceToken: import_zod3.z.number().optional()
277229
277247
  });
277230
277248
  var wireClaimSummarySchema = wireClaimBaseSchema.pick({
277231
277249
  claimId: true,
277232
- reason: true,
277250
+ description: true,
277233
277251
  declaredAt: true,
277234
277252
  expiresAt: true,
277235
277253
  entityType: true,
@@ -277279,18 +277297,22 @@ var modelClaimSchema = import_zod3.z.object({
277279
277297
  id: import_zod3.z.string(),
277280
277298
  actor: import_zod3.z.string(),
277281
277299
  participantKind: wireParticipantKindSchema,
277282
- /** Human-readable phase (`'editing'`). */
277283
- reason: import_zod3.z.string(),
277300
+ /** Peer-visible description of the work (`'rewriting the risk section'`).
277301
+ * Optional: a claim may be declared without one. */
277284
277302
  description: import_zod3.z.string().optional(),
277285
277303
  field: import_zod3.z.string().optional(),
277286
277304
  status: import_zod3.z.enum(["active", "queued"]).optional(),
277287
277305
  position: import_zod3.z.number().optional(),
277288
277306
  expiresAt: import_zod3.z.number(),
277307
+ /** The grant's fencing token (Option B), present on a claim you hold. */
277308
+ fenceToken: import_zod3.z.number().optional(),
277289
277309
  target: modelTargetSchema
277290
277310
  }).readonly();
277291
277311
  var claimBeginPayloadSchema = targetRefSchema.extend({
277292
277312
  claimId: import_zod3.z.string(),
277293
- reason: import_zod3.z.string(),
277313
+ /** Peer-visible description of the work. The server stamps `'editing'` when a
277314
+ * frame carries none. */
277315
+ description: import_zod3.z.string().optional(),
277294
277316
  /** Hint for `expiresAt`; the server caps it. */
277295
277317
  estimatedMs: import_zod3.z.number().optional(),
277296
277318
  /**
@@ -277374,7 +277396,15 @@ var commitOperationSchema = writeGuardSchema.extend({
277374
277396
  id: import_zod3.z.string().nullish(),
277375
277397
  input: import_zod3.z.record(import_zod3.z.string(), import_zod3.z.unknown()).nullish(),
277376
277398
  /** Per-op client tx id, echoed on the broadcast delta. */
277377
- transactionId: import_zod3.z.string().nullish()
277399
+ transactionId: import_zod3.z.string().nullish(),
277400
+ /**
277401
+ * The fencing token from the held claim this write belongs to (Option B).
277402
+ * Present only on a write issued under a claim that was granted one; the
277403
+ * server checks it against the entity's persisted high-water and rejects a
277404
+ * stale token. Absent (nullish) on every unclaimed write — those are governed
277405
+ * by version-CAS and the Option A blind-write guard, unchanged.
277406
+ */
277407
+ fenceToken: import_zod3.z.number().nullish()
277378
277408
  });
277379
277409
  var presenceKindSchema = import_zod3.z.enum(["enter", "update", "leave"]);
277380
277410
  var presenceActivitySchema = targetRefSchema.extend({
@@ -279769,10 +279799,18 @@ var import_path = require("path");
279769
279799
  function readProjectDatabaseUrl(cwd = process.cwd()) {
279770
279800
  const fromEnv = process.env.DATABASE_URL ?? process.env.ABLO_DATABASE_URL;
279771
279801
  if (fromEnv) return fromEnv;
279772
- for (const name of [".env.local", ".env"]) {
279773
- const path = (0, import_path.resolve)(cwd, name);
279802
+ return readProjectEnvValue("DATABASE_URL", cwd);
279803
+ }
279804
+ function readProjectWriteDatabaseUrl(cwd = process.cwd()) {
279805
+ const fromEnv = process.env.ABLO_WRITE_DATABASE_URL;
279806
+ if (fromEnv) return fromEnv;
279807
+ return readProjectEnvValue("ABLO_WRITE_DATABASE_URL", cwd);
279808
+ }
279809
+ function readProjectEnvValue(variable, cwd) {
279810
+ for (const filename of [".env.local", ".env"]) {
279811
+ const path = (0, import_path.resolve)(cwd, filename);
279774
279812
  if (!(0, import_fs2.existsSync)(path)) continue;
279775
- const match = /^DATABASE_URL=(.+)$/m.exec((0, import_fs2.readFileSync)(path, "utf8"));
279813
+ const match = new RegExp(`^${variable}=(.+)$`, "m").exec((0, import_fs2.readFileSync)(path, "utf8"));
279776
279814
  if (match?.[1]) return match[1].trim().replace(/^["']|["']$/g, "");
279777
279815
  }
279778
279816
  return null;
@@ -280892,6 +280930,18 @@ async function push(argv) {
280892
280930
  console.log(import_picocolors4.default.yellow(` Applied ${body.warnings.length} destructive change(s):`));
280893
280931
  for (const w2 of body.warnings) console.log(import_picocolors4.default.yellow(fmtSignal(w2)));
280894
280932
  }
280933
+ if (body.provisioningDeferred === true) {
280934
+ console.log(
280935
+ import_picocolors4.default.yellow(
280936
+ ` Your schema is registered, but its tables were not created on this plane \u2014 the engine's runtime role does not run DDL here.`
280937
+ )
280938
+ );
280939
+ console.log(
280940
+ import_picocolors4.default.dim(
280941
+ ` New models are recorded as metadata; provision their tables out-of-band before you read or write them.`
280942
+ )
280943
+ );
280944
+ }
280895
280945
  }
280896
280946
  return;
280897
280947
  }
@@ -281195,6 +281245,21 @@ function describeRemoteFailure(failure) {
281195
281245
  label: `published tables cannot replicate UPDATE/DELETE${failure.actual ? ` (${failure.actual})` : ""}`,
281196
281246
  fix: failure.fix
281197
281247
  };
281248
+ case "write_role":
281249
+ return { label: "ABLO_WRITE_DATABASE_URL is not a scoped DML role", fix: failure.fix };
281250
+ case "row_security":
281251
+ return { label: "the direct-write role does not enforce row_security", fix: failure.fix };
281252
+ case "schema_privileges":
281253
+ return { label: "the direct-write role has unsafe schema privileges", fix: failure.fix };
281254
+ case "idempotency_ledger":
281255
+ return { label: "ablo_idempotency is missing or has unsafe grants", fix: failure.fix };
281256
+ case "table_privileges":
281257
+ return {
281258
+ label: `the direct-write role lacks application DML${failure.actual ? ` (${failure.actual})` : ""}`,
281259
+ fix: failure.fix
281260
+ };
281261
+ case "logical_marker":
281262
+ return { label: "the direct-write role cannot emit the correlation marker", fix: failure.fix };
281198
281263
  default:
281199
281264
  return { label: failure.item, fix: failure.fix };
281200
281265
  }
@@ -281217,7 +281282,10 @@ async function requestRemoteValidation(input) {
281217
281282
  res = await doFetch(validateEndpoint(input.apiUrl), {
281218
281283
  method: "POST",
281219
281284
  headers: { "content-type": "application/json", authorization: `Bearer ${input.apiKey}` },
281220
- body: JSON.stringify({ connectionString: input.connectionString })
281285
+ body: JSON.stringify({
281286
+ connectionString: input.connectionString,
281287
+ ...input.writeConnectionString ? { writeConnectionString: input.writeConnectionString } : {}
281288
+ })
281221
281289
  });
281222
281290
  } catch (err) {
281223
281291
  return {
@@ -281247,15 +281315,54 @@ async function requestRemoteValidation(input) {
281247
281315
  };
281248
281316
  }
281249
281317
 
281318
+ // src/source/migrations.ts
281319
+ init_cjs_shims();
281320
+ function idempotencyLedgerMigrations() {
281321
+ return [
281322
+ {
281323
+ name: "ablo_idempotency",
281324
+ up: `CREATE TABLE IF NOT EXISTS ablo_idempotency (
281325
+ client_tx_id TEXT PRIMARY KEY,
281326
+ response JSONB NOT NULL,
281327
+ request_hash TEXT,
281328
+ expires_at TIMESTAMPTZ NOT NULL DEFAULT 'infinity',
281329
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
281330
+ );`
281331
+ },
281332
+ {
281333
+ name: "ablo_idempotency_request_hash",
281334
+ // Nullable only for rows created by older adapter versions. New writes
281335
+ // always populate it; replaying a legacy NULL row fails closed because
281336
+ // the adapter cannot prove that the intent matches.
281337
+ up: `ALTER TABLE ablo_idempotency
281338
+ ADD COLUMN IF NOT EXISTS request_hash TEXT;`
281339
+ },
281340
+ {
281341
+ name: "ablo_idempotency_permanent_retention",
281342
+ up: `ALTER TABLE ablo_idempotency
281343
+ ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ NOT NULL DEFAULT 'infinity';`
281344
+ }
281345
+ ];
281346
+ }
281347
+
281250
281348
  // src/cli/connect.ts
281251
281349
  var ABLO_PUBLICATION = "ablo_publication";
281252
281350
  var ABLO_REPLICATION_ROLE = "ablo_replicator";
281351
+ var ABLO_WRITE_ROLE = "ablo_writer";
281352
+ var DIRECT_DATA_SOURCE_ROUTES = [
281353
+ "public-allowlist",
281354
+ "privatelink",
281355
+ "peering",
281356
+ "vpn"
281357
+ ];
281253
281358
  function parseConnectArgs(argv) {
281254
281359
  let check2 = false;
281255
281360
  let register = false;
281256
281361
  let auditInfra = false;
281257
281362
  let tables = [];
281258
281363
  let role = ABLO_REPLICATION_ROLE;
281364
+ let writeRole = ABLO_WRITE_ROLE;
281365
+ let route = "public-allowlist";
281259
281366
  for (let i = 0; i < argv.length; i++) {
281260
281367
  const arg = argv[i];
281261
281368
  switch (arg) {
@@ -281276,19 +281383,41 @@ function parseConnectArgs(argv) {
281276
281383
  case "--role":
281277
281384
  role = argv[++i] ?? role;
281278
281385
  break;
281386
+ case "--write-role":
281387
+ writeRole = argv[++i] ?? writeRole;
281388
+ break;
281389
+ case "--route": {
281390
+ const value = argv[++i] ?? "";
281391
+ if (!DIRECT_DATA_SOURCE_ROUTES.includes(value)) {
281392
+ throw new AbloValidationError(
281393
+ `invalid direct route: ${value || "(missing)"} (expected ${DIRECT_DATA_SOURCE_ROUTES.join(", ")})`,
281394
+ { code: "cli_invalid_arguments" }
281395
+ );
281396
+ }
281397
+ route = value;
281398
+ break;
281399
+ }
281279
281400
  default:
281280
281401
  throw new AbloValidationError(`unknown flag: ${arg}`, { code: "cli_invalid_arguments" });
281281
281402
  }
281282
281403
  }
281283
- return { check: check2, register, auditInfra, tables, role };
281404
+ if (role === writeRole) {
281405
+ throw new AbloValidationError("replication and write roles must be different", {
281406
+ code: "cli_invalid_arguments"
281407
+ });
281408
+ }
281409
+ return { check: check2, register, auditInfra, tables, role, writeRole, route };
281284
281410
  }
281285
281411
  function quoteIdent(id) {
281286
281412
  return `"${id.replace(/"/g, '""')}"`;
281287
281413
  }
281288
281414
  function connectSetupSql(input) {
281289
281415
  const role = input.role && input.role.length > 0 ? input.role : ABLO_REPLICATION_ROLE;
281416
+ const writeRole = input.writeRole && input.writeRole.length > 0 ? input.writeRole : ABLO_WRITE_ROLE;
281290
281417
  const tables = input.tables ?? [];
281291
281418
  const publicationTarget = tables.length > 0 ? `FOR TABLE ${tables.map(quoteIdent).join(", ")}` : "FOR ALL TABLES";
281419
+ const applicationGrant = tables.length > 0 ? `GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE ${tables.map(quoteIdent).join(", ")} TO ${quoteIdent(writeRole)};` : `GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO ${quoteIdent(writeRole)};`;
281420
+ const ledger = idempotencyLedgerMigrations().map((migration) => migration.up);
281292
281421
  return [
281293
281422
  // 1. Turn on logical decoding. Requires a restart (it's not reloadable).
281294
281423
  `ALTER SYSTEM SET wal_level = 'logical';`,
@@ -281298,18 +281427,58 @@ function connectSetupSql(input) {
281298
281427
  `CREATE ROLE ${quoteIdent(role)} WITH REPLICATION LOGIN PASSWORD '<password>';`,
281299
281428
  `GRANT SELECT ON ALL TABLES IN SCHEMA public TO ${quoteIdent(role)};`,
281300
281429
  // Future tables get SELECT automatically, so the publication doesn't outgrow the grant.
281301
- `ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO ${quoteIdent(role)};`
281430
+ `ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO ${quoteIdent(role)};`,
281431
+ // 4. A distinct DML role: no replication, role administration, ownership,
281432
+ // schema creation, or DDL. It is subject to RLS on every transaction.
281433
+ `CREATE ROLE ${quoteIdent(writeRole)} WITH LOGIN PASSWORD '<write-password>' NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE NOREPLICATION NOINHERIT;`,
281434
+ `ALTER ROLE ${quoteIdent(writeRole)} SET row_security = on;`,
281435
+ // PUBLIC can carry CREATE on older Postgres clusters. A role-level REVOKE
281436
+ // cannot override that inherited grant, so harden the schema explicitly.
281437
+ `REVOKE CREATE ON SCHEMA public FROM PUBLIC;`,
281438
+ `GRANT USAGE ON SCHEMA public TO ${quoteIdent(writeRole)};`,
281439
+ applicationGrant,
281440
+ `GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ${quoteIdent(writeRole)};`,
281441
+ ...tables.length === 0 ? [
281442
+ `ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${quoteIdent(writeRole)};`,
281443
+ `ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO ${quoteIdent(writeRole)};`
281444
+ ] : [],
281445
+ // 5. Direct uses the durable replay ledger but deliberately no outbox.
281446
+ ...ledger,
281447
+ `REVOKE ALL ON TABLE public.ablo_idempotency FROM PUBLIC;`,
281448
+ `GRANT SELECT, INSERT, UPDATE ON TABLE public.ablo_idempotency TO ${quoteIdent(writeRole)};`,
281449
+ `REVOKE DELETE ON TABLE public.ablo_idempotency FROM ${quoteIdent(writeRole)};`,
281450
+ // Grant every pg_logical_emit_message variant by lookup instead of one
281451
+ // literal signature: PostgreSQL 17 adds an optional fourth `flush`
281452
+ // parameter, so the historical three-argument form no longer exists there
281453
+ // and a signature-pinned GRANT fails on an otherwise healthy database.
281454
+ `DO $$
281455
+ DECLARE fn regprocedure;
281456
+ BEGIN
281457
+ FOR fn IN
281458
+ SELECT p.oid::regprocedure
281459
+ FROM pg_proc p
281460
+ JOIN pg_namespace n ON n.oid = p.pronamespace
281461
+ WHERE n.nspname = 'pg_catalog' AND p.proname = 'pg_logical_emit_message'
281462
+ LOOP
281463
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO ${quoteIdent(writeRole)}', fn);
281464
+ END LOOP;
281465
+ END $$;`
281302
281466
  ];
281303
281467
  }
281304
281468
  function printConnectRecipe(args) {
281305
- const sql = connectSetupSql({ tables: args.tables, role: args.role });
281469
+ const sql = connectSetupSql({
281470
+ tables: args.tables,
281471
+ role: args.role,
281472
+ writeRole: args.writeRole
281473
+ });
281306
281474
  console.log(`
281307
- ${brand("ablo")} ${import_picocolors6.default.dim("connect")} ${import_picocolors6.default.dim("logical replication \u2014 the read path (your writes stay on your own backend)")}
281475
+ ${brand("ablo")} ${import_picocolors6.default.dim("connect")} ${import_picocolors6.default.dim("direct writes + WAL-settled sync")}
281308
281476
  `);
281309
281477
  console.log(
281310
- ` Ablo consumes your write-ahead log (WAL) via logical replication and ${import_picocolors6.default.bold("never")} runs DDL,
281311
- owns, hosts, or migrates your schema. Your app continues to own the write path \u2014 Ablo tails
281312
- the changes and serves them as live shapes. Run this once against your Postgres ${import_picocolors6.default.dim("(as a superuser / DB owner)")}:
281478
+ ` Ablo applies coordinated writes directly to your Postgres with a scoped DML role. WAL
281479
+ observes what committed, orders it with external writes, and confirms it in the sync log.
281480
+ Your database stays authoritative; Ablo never owns or migrates your application tables.
281481
+ Run this once against your Postgres ${import_picocolors6.default.dim("(as a superuser / DB owner)")}:
281313
281482
  `
281314
281483
  );
281315
281484
  console.log(` ${import_picocolors6.default.bold("1.")} Enable logical decoding ${import_picocolors6.default.dim("(then RESTART Postgres \u2014 wal_level is not reloadable)")}`);
@@ -281327,7 +281496,7 @@ function printConnectRecipe(args) {
281327
281496
  console.log(import_picocolors6.default.dim(` (Scope it with ${import_picocolors6.default.bold("ablo connect --tables a,b,c")} to publish a subset.)`));
281328
281497
  }
281329
281498
  console.log(`
281330
- ${import_picocolors6.default.bold("3.")} Create a least-privilege replication role ${import_picocolors6.default.dim("(pick your own password)")}`);
281499
+ ${import_picocolors6.default.bold("3.")} Create the scoped replication role ${import_picocolors6.default.dim("(pick your own replication password)")}`);
281331
281500
  console.log(` ${import_picocolors6.default.cyan(sql[2])}`);
281332
281501
  console.log(` ${import_picocolors6.default.cyan(sql[3])}`);
281333
281502
  console.log(` ${import_picocolors6.default.cyan(sql[4])}`);
@@ -281339,15 +281508,31 @@ function printConnectRecipe(args) {
281339
281508
  );
281340
281509
  console.log(
281341
281510
  `
281342
- ${import_picocolors6.default.bold("4.")} Put the role's connection string in ${import_picocolors6.default.bold("DATABASE_URL")}, then verify:
281511
+ ${import_picocolors6.default.bold("4.")} Create the separate DML role and permanent idempotency ledger ` + import_picocolors6.default.dim("(pick your own write password)")
281512
+ );
281513
+ for (const statement of sql.slice(5)) {
281514
+ for (const line of statement.split("\n")) console.log(` ${import_picocolors6.default.cyan(line)}`);
281515
+ }
281516
+ console.log(
281517
+ import_picocolors6.default.dim(
281518
+ ` The writer gets row DML + ledger access only. It has no REPLICATION, schema CREATE,
281519
+ role administration, database creation, ownership, or customer-table DDL. Direct uses
281520
+ ${import_picocolors6.default.bold("ablo_idempotency")} but no outbox; WAL carries the committed row changes.`
281521
+ )
281522
+ );
281523
+ console.log(
281524
+ `
281525
+ ${import_picocolors6.default.bold("5.")} Configure both scoped connections, then verify:
281526
+ ${import_picocolors6.default.bold("DATABASE_URL")} ${import_picocolors6.default.dim(`\u2192 ${args.role} (replication only)`)}
281527
+ ${import_picocolors6.default.bold("ABLO_WRITE_DATABASE_URL")} ${import_picocolors6.default.dim(`\u2192 ${args.writeRole} (DML only)`)}
281343
281528
  ${import_picocolors6.default.cyan("npx ablo connect --check")}
281344
281529
  `
281345
281530
  );
281346
281531
  console.log(
281347
281532
  import_picocolors6.default.dim(
281348
- ` Reminder: this is the READ path \u2014 Ablo tails your WAL and never writes to your database.
281349
- Your writes keep going through your own backend. (Ablo HOSTING your rows, or dialing in via
281350
- ${import_picocolors6.default.bold("databaseUrl")}, is the deprecated posture \u2014 see the read-path decision doc.)`
281533
+ ` Reachable databases use this direct path. If no inbound route can be established, use the
281534
+ signed ${import_picocolors6.default.bold("dataSource()")} endpoint fallback; its correlated events confirm writes
281535
+ without an Ablo-side customer database socket.`
281351
281536
  )
281352
281537
  );
281353
281538
  console.log();
@@ -281448,6 +281633,76 @@ On RDS: GRANT rds_replication TO <your_role>;`
281448
281633
  }
281449
281634
  return items;
281450
281635
  }
281636
+ async function probeDirectWriteReadiness(sql, opts = {}) {
281637
+ const schema = opts.schema ?? "public";
281638
+ const publication = opts.publication ?? ABLO_PUBLICATION;
281639
+ const items = [];
281640
+ const roleRows = await sql.unsafe(
281641
+ `SELECT rolname, rolsuper, rolbypassrls, rolcreatedb, rolcreaterole, rolreplication
281642
+ FROM pg_roles WHERE rolname = current_user`
281643
+ );
281644
+ const role = roleRows[0];
281645
+ const dangerous = Boolean(
281646
+ !role || role.rolsuper || role.rolbypassrls || role.rolcreatedb || role.rolcreaterole || role.rolreplication
281647
+ );
281648
+ items.push(
281649
+ dangerous ? {
281650
+ ok: false,
281651
+ label: `ABLO_WRITE_DATABASE_URL is not a scoped DML role`,
281652
+ fix: `Use ${ABLO_WRITE_ROLE} from the setup recipe: NOSUPERUSER, NOBYPASSRLS, NOCREATEDB, NOCREATEROLE, NOREPLICATION.`
281653
+ } : { ok: true, label: `write role ${import_picocolors6.default.bold(role?.rolname ?? ABLO_WRITE_ROLE)} is DML-only` }
281654
+ );
281655
+ const schemaRows = await sql.unsafe(
281656
+ `SELECT
281657
+ has_schema_privilege(current_user, $1, 'USAGE') AS usage,
281658
+ has_schema_privilege(current_user, $1, 'CREATE') AS create,
281659
+ current_setting('row_security') AS row_security`,
281660
+ [schema]
281661
+ );
281662
+ const schemaRow = schemaRows[0];
281663
+ items.push(
281664
+ schemaRow?.usage && !schemaRow.create && schemaRow.row_security === "on" ? { ok: true, label: `writer uses ${schema} with RLS on and no schema CREATE` } : {
281665
+ ok: false,
281666
+ label: `writer schema/RLS privileges are not least-privilege`,
281667
+ fix: `REVOKE CREATE ON SCHEMA ${quoteIdent(schema)} FROM PUBLIC; GRANT USAGE ON SCHEMA ${quoteIdent(schema)} TO ${quoteIdent(role?.rolname ?? ABLO_WRITE_ROLE)}; ALTER ROLE ${quoteIdent(role?.rolname ?? ABLO_WRITE_ROLE)} SET row_security = on;`
281668
+ }
281669
+ );
281670
+ const ledgerName = `${quoteIdent(schema)}.${quoteIdent("ablo_idempotency")}`;
281671
+ const ledgerRows = await sql.unsafe(
281672
+ `SELECT
281673
+ to_regclass($1) IS NOT NULL AS present,
281674
+ CASE WHEN to_regclass($1) IS NULL THEN false ELSE has_table_privilege(current_user, $1, 'SELECT,INSERT,UPDATE') END AS writes,
281675
+ CASE WHEN to_regclass($1) IS NULL THEN false ELSE has_table_privilege(current_user, $1, 'DELETE') END AS deletes`,
281676
+ [ledgerName]
281677
+ );
281678
+ const ledger = ledgerRows[0];
281679
+ items.push(
281680
+ ledger?.present && ledger.writes && !ledger.deletes ? { ok: true, label: `${import_picocolors6.default.bold("ablo_idempotency")} is durable and protected from DELETE` } : {
281681
+ ok: false,
281682
+ label: `${import_picocolors6.default.bold("ablo_idempotency")} is missing or has unsafe grants`,
281683
+ fix: `Apply the DML-role and idempotency-ledger statements printed by ${import_picocolors6.default.bold("ablo connect")}.`
281684
+ }
281685
+ );
281686
+ const tableRows = await sql.unsafe(
281687
+ `SELECT format('%I.%I', schemaname, tablename) AS relation
281688
+ FROM pg_publication_tables
281689
+ WHERE pubname = $1 AND schemaname = $2 AND tablename <> 'ablo_idempotency'
281690
+ AND NOT has_table_privilege(
281691
+ current_user,
281692
+ format('%I.%I', schemaname, tablename),
281693
+ 'SELECT,INSERT,UPDATE,DELETE'
281694
+ )`,
281695
+ [publication, schema]
281696
+ );
281697
+ items.push(
281698
+ tableRows.length === 0 ? { ok: true, label: `writer can apply DML to every published application table` } : {
281699
+ ok: false,
281700
+ label: `writer lacks DML on ${tableRows.length} published table${tableRows.length === 1 ? "" : "s"}`,
281701
+ fix: `Grant SELECT, INSERT, UPDATE, DELETE on: ${tableRows.map((row) => row.relation).join(", ")}`
281702
+ }
281703
+ );
281704
+ return items;
281705
+ }
281451
281706
  async function auditTenantSyncInfra(sql) {
281452
281707
  const artifacts = [];
281453
281708
  for (const name of SYNC_INFRA_RELATIONS) {
@@ -281476,12 +281731,24 @@ function requireDatabaseUrl(verb) {
281476
281731
  }
281477
281732
  return dbUrl;
281478
281733
  }
281479
- async function probeAndReport(dbUrl) {
281734
+ function requireWriteDatabaseUrl(verb) {
281735
+ const dbUrl = readProjectWriteDatabaseUrl();
281736
+ if (!dbUrl) {
281737
+ console.error(
281738
+ import_picocolors6.default.red(" No ABLO_WRITE_DATABASE_URL found (checked process env, .env.local, .env).") + import_picocolors6.default.dim(
281739
+ ` Set it to the ${ABLO_WRITE_ROLE} connection printed by ${import_picocolors6.default.bold("ablo connect")}, then re-run ${import_picocolors6.default.bold(`ablo connect ${verb}`)}. The replication credential is never reused for DML.`
281740
+ )
281741
+ );
281742
+ process.exit(1);
281743
+ }
281744
+ return dbUrl;
281745
+ }
281746
+ async function probeAndReport(dbUrl, kind = "replication") {
281480
281747
  const sql = src_default(dbUrl, { max: 1, prepare: false, connect_timeout: 10, onnotice: () => {
281481
281748
  } });
281482
281749
  let items;
281483
281750
  try {
281484
- items = await probeReadiness(sql);
281751
+ items = kind === "replication" ? await probeReadiness(sql) : await probeDirectWriteReadiness(sql);
281485
281752
  } catch (err) {
281486
281753
  await sql.end({ timeout: 2 }).catch(() => void 0);
281487
281754
  const dial = dialFailureReason(err);
@@ -281494,11 +281761,11 @@ async function probeAndReport(dbUrl) {
281494
281761
  for (const item of items) printCheckItem(item);
281495
281762
  return { kind: "probed", failures: items.filter((i) => !i.ok).length };
281496
281763
  }
281497
- async function runRemoteCheck(dbUrl, localReason) {
281764
+ async function runRemoteCheck(dbUrl, writeDbUrl, localReason) {
281498
281765
  console.log(
281499
- ` This machine can't reach the database (${import_picocolors6.default.dim(localReason)}).
281500
- That is not the verdict: replication runs from Ablo's infrastructure, not from here.
281501
- Asking Ablo to check the database from its side\u2026
281766
+ ` This machine can't reach one or both scoped connections (${import_picocolors6.default.dim(localReason)}).
281767
+ That is not the verdict: direct writes and replication run from Ablo's infrastructure.
281768
+ Asking Ablo to check both roles from its side\u2026
281502
281769
  `
281503
281770
  );
281504
281771
  const apiKey = resolveApiKey();
@@ -281511,7 +281778,12 @@ async function runRemoteCheck(dbUrl, localReason) {
281511
281778
  process.exit(1);
281512
281779
  }
281513
281780
  const apiUrl2 = (process.env.ABLO_API_URL ?? DEFAULT_URL).replace(/\/+$/, "");
281514
- const result = await requestRemoteValidation({ apiUrl: apiUrl2, apiKey, connectionString: dbUrl });
281781
+ const result = await requestRemoteValidation({
281782
+ apiUrl: apiUrl2,
281783
+ apiKey,
281784
+ connectionString: dbUrl,
281785
+ writeConnectionString: writeDbUrl
281786
+ });
281515
281787
  if (!result.ok) {
281516
281788
  console.error(import_picocolors6.default.red(` The engine-side check failed: ${result.message}`));
281517
281789
  if (result.code === "forbidden") {
@@ -281524,13 +281796,12 @@ async function runRemoteCheck(dbUrl, localReason) {
281524
281796
  }
281525
281797
  if (!result.reachable) {
281526
281798
  console.error(
281527
- ` ${import_picocolors6.default.red("\u2717")} Ablo's infrastructure can't reach it either${result.reason ? ` ${import_picocolors6.default.dim(`(${result.reason})`)}` : ""}.`
281799
+ ` ${import_picocolors6.default.red("\u2717")} Ablo's infrastructure can't reach both direct connections${result.reason ? ` ${import_picocolors6.default.dim(`(${result.reason})`)}` : ""}.`
281528
281800
  );
281529
281801
  console.error(
281530
281802
  import_picocolors6.default.dim(
281531
- ` Replication needs a host Ablo's servers can dial \u2014 a localhost or private-network
281532
- Postgres can't be replicated by the hosted service. Use a reachable host (or a tunnel),
281533
- or the signed ${import_picocolors6.default.bold("dataSource()")} endpoint fallback.
281803
+ ` Direct needs a route Ablo's servers can dial \u2014 public allowlist, PrivateLink, peering,
281804
+ or VPN. Only when no inbound route can exist, use the signed ${import_picocolors6.default.bold("dataSource()")} endpoint fallback.
281534
281805
  `
281535
281806
  )
281536
281807
  );
@@ -281543,7 +281814,7 @@ async function runRemoteCheck(dbUrl, localReason) {
281543
281814
  console.log();
281544
281815
  if (result.ready) {
281545
281816
  console.log(
281546
- ` ${import_picocolors6.default.green("\u2713")} Ready \u2014 checked from Ablo's infrastructure. Ablo can connect and tail this database's WAL.
281817
+ ` ${import_picocolors6.default.green("\u2713")} Ready \u2014 checked from Ablo's infrastructure. Both direct DML and WAL settlement are available.
281547
281818
  `
281548
281819
  );
281549
281820
  process.exit(0);
@@ -281557,17 +281828,28 @@ async function runRemoteCheck(dbUrl, localReason) {
281557
281828
  }
281558
281829
  async function runCheck() {
281559
281830
  const dbUrl = requireDatabaseUrl("--check");
281831
+ const writeDbUrl = requireWriteDatabaseUrl("--check");
281832
+ console.log(`
281833
+ ${brand("ablo")} ${import_picocolors6.default.dim("connect --check")} ${import_picocolors6.default.dim("direct-write + WAL readiness")}
281834
+ `);
281835
+ console.log(` ${import_picocolors6.default.bold("Replication role")}
281836
+ `);
281837
+ const replication = await probeAndReport(dbUrl, "replication");
281560
281838
  console.log(`
281561
- ${brand("ablo")} ${import_picocolors6.default.dim("connect --check")} ${import_picocolors6.default.dim("logical-replication readiness")}
281839
+ ${import_picocolors6.default.bold("Direct-write role")}
281562
281840
  `);
281563
- const outcome = await probeAndReport(dbUrl);
281564
- if (outcome.kind === "no-dial") {
281565
- return runRemoteCheck(dbUrl, outcome.reason);
281841
+ const write = await probeAndReport(writeDbUrl, "write");
281842
+ if (replication.kind === "no-dial" || write.kind === "no-dial") {
281843
+ const reasons = [
281844
+ replication.kind === "no-dial" ? `replication: ${replication.reason}` : null,
281845
+ write.kind === "no-dial" ? `write: ${write.reason}` : null
281846
+ ].filter((reason) => reason !== null);
281847
+ return runRemoteCheck(dbUrl, writeDbUrl, reasons.join("; "));
281566
281848
  }
281567
- const failures = outcome.failures;
281849
+ const failures = replication.failures + write.failures;
281568
281850
  console.log();
281569
281851
  if (failures === 0) {
281570
- console.log(` ${import_picocolors6.default.green("\u2713")} Ready \u2014 Ablo can connect and tail this database's WAL.
281852
+ console.log(` ${import_picocolors6.default.green("\u2713")} Ready \u2014 Ablo can apply scoped DML and settle it from WAL.
281571
281853
  `);
281572
281854
  process.exit(0);
281573
281855
  }
@@ -281580,8 +281862,9 @@ async function runCheck() {
281580
281862
  function registerEndpoint(baseUrl2) {
281581
281863
  return `${baseUrl2.replace(/\/+$/, "")}/api/v1/datasources`;
281582
281864
  }
281583
- async function runRegister() {
281865
+ async function runRegister(args) {
281584
281866
  const dbUrl = requireDatabaseUrl("--register");
281867
+ const writeDbUrl = requireWriteDatabaseUrl("--register");
281585
281868
  const apiKey = resolveApiKey();
281586
281869
  if (!apiKey) {
281587
281870
  console.error(
@@ -281590,20 +281873,32 @@ async function runRegister() {
281590
281873
  process.exit(1);
281591
281874
  }
281592
281875
  console.log(`
281593
- ${brand("ablo")} ${import_picocolors6.default.dim("connect --register")} ${import_picocolors6.default.dim("register this database for replication")}
281876
+ ${brand("ablo")} ${import_picocolors6.default.dim("connect --register")} ${import_picocolors6.default.dim("register a direct DataSource")}
281877
+ `);
281878
+ console.log(` ${import_picocolors6.default.bold("Replication role")}
281879
+ `);
281880
+ const replication = await probeAndReport(dbUrl, "replication");
281881
+ console.log(`
281882
+ ${import_picocolors6.default.bold("Direct-write role")}
281594
281883
  `);
281595
- const outcome = await probeAndReport(dbUrl);
281596
- if (outcome.kind === "no-dial") {
281884
+ const write = await probeAndReport(writeDbUrl, "write");
281885
+ const noDial = [
281886
+ replication.kind === "no-dial" ? `replication: ${replication.reason}` : null,
281887
+ write.kind === "no-dial" ? `write: ${write.reason}` : null
281888
+ ].filter((reason) => reason !== null);
281889
+ const failures = (replication.kind === "probed" ? replication.failures : 0) + (write.kind === "probed" ? write.failures : 0);
281890
+ if (noDial.length > 0) {
281597
281891
  console.log(
281598
- ` This machine can't reach the database (${import_picocolors6.default.dim(outcome.reason)}) \u2014 continuing anyway.
281599
- Replication runs from Ablo's infrastructure, which validates the database during
281600
- registration and refuses with the full checklist if it isn't ready.
281892
+ ` This machine can't reach one or both scoped connections (${import_picocolors6.default.dim(noDial.join("; "))}) \u2014 continuing anyway.
281893
+ Ablo validates both credentials from the infrastructure that will use them and refuses
281894
+ registration unless replication and direct DML are both ready.
281601
281895
  `
281602
281896
  );
281603
- } else if (outcome.failures > 0) {
281897
+ }
281898
+ if (failures > 0) {
281604
281899
  console.log(
281605
281900
  `
281606
- ${import_picocolors6.default.red(`${outcome.failures} item${outcome.failures === 1 ? "" : "s"} to fix`)} ${import_picocolors6.default.dim("\u2014 a database that isn\u2019t replication-ready can\u2019t stream. Fix the above, then re-run.")}
281901
+ ${import_picocolors6.default.red(`${failures} item${failures === 1 ? "" : "s"} to fix`)} ${import_picocolors6.default.dim("\u2014 direct registration requires both scoped roles. Fix the above, then re-run.")}
281607
281902
  `
281608
281903
  );
281609
281904
  process.exit(1);
@@ -281614,7 +281909,12 @@ async function runRegister() {
281614
281909
  res = await fetch(registerEndpoint(apiUrl2), {
281615
281910
  method: "POST",
281616
281911
  headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
281617
- body: JSON.stringify({ connectionString: dbUrl })
281912
+ body: JSON.stringify({
281913
+ connection: "direct",
281914
+ connectionString: dbUrl,
281915
+ writeConnectionString: writeDbUrl,
281916
+ route: args.route
281917
+ })
281618
281918
  });
281619
281919
  } catch (err) {
281620
281920
  console.error(import_picocolors6.default.red(`
@@ -281624,9 +281924,11 @@ async function runRegister() {
281624
281924
  }
281625
281925
  if (res.ok) {
281626
281926
  const body2 = await res.json().catch(() => ({}));
281927
+ const statusNote = body2.status === "active" ? `${args.route}, active` : args.route;
281627
281928
  console.log(
281628
281929
  `
281629
- ${import_picocolors6.default.green("\u2713")} Registered${body2.host ? ` ${import_picocolors6.default.dim(body2.host)}` : ""}${body2.id ? ` ${import_picocolors6.default.dim(`(${body2.id})`)}` : ""} \u2014 Ablo will replicate this database on the next sync.
281930
+ ${import_picocolors6.default.green("\u2713")} Registered${body2.host ? ` ${import_picocolors6.default.dim(body2.host)}` : ""}${body2.id ? ` ${import_picocolors6.default.dim(`(${body2.id})`)}` : ""} as a direct DataSource (${statusNote}).
281931
+ Customer COMMIT is durable acceptance; correlated WAL promotes queued writes to confirmed.
281630
281932
  `
281631
281933
  );
281632
281934
  process.exit(0);
@@ -281642,20 +281944,20 @@ async function runRegister() {
281642
281944
  console.error(
281643
281945
  import_picocolors6.default.dim(` This deployment can\u2019t accept connection strings \u2014 use a self-hosted/hosted engine, or the signed endpoint fallback.`)
281644
281946
  );
281645
- } else if (code === "database_not_replication_ready") {
281947
+ } else if (code === "database_not_replication_ready" || code === "data_source_blocked") {
281646
281948
  for (const f of body.details?.failures ?? []) {
281647
281949
  console.error(` ${import_picocolors6.default.red("\u2717")} ${import_picocolors6.default.bold(f.item ?? "item")}${f.actual ? import_picocolors6.default.dim(` (${f.actual})`) : ""}`);
281648
281950
  if (f.fix) for (const line of f.fix.split("\n")) console.error(` ${import_picocolors6.default.red("\u2022")} ${line}`);
281649
281951
  }
281650
281952
  console.error(import_picocolors6.default.dim(`
281651
281953
  Apply the fixes, verify with ${import_picocolors6.default.bold("ablo connect --check")}, then re-run.`));
281652
- } else if (code === "database_unreachable") {
281954
+ } else if (code === "database_unreachable" || code === "source_unreachable") {
281653
281955
  if (body.details?.reason) console.error(import_picocolors6.default.dim(` ${body.details.reason}`));
281654
281956
  console.error(
281655
281957
  import_picocolors6.default.dim(
281656
281958
  ` Ablo's servers must be able to reach this database \u2014 a localhost or private-network
281657
- Postgres can't be replicated by the hosted service. Use a reachable host (or a tunnel),
281658
- or the signed ${import_picocolors6.default.bold("dataSource()")} endpoint fallback.`
281959
+ Postgres can't use the direct path. Establish an allowlist, PrivateLink, peering, or VPN.
281960
+ Only when no inbound route is possible, register the signed ${import_picocolors6.default.bold("dataSource()")} endpoint fallback.`
281659
281961
  )
281660
281962
  );
281661
281963
  }
@@ -281708,7 +282010,7 @@ async function connect(argv) {
281708
282010
  return;
281709
282011
  }
281710
282012
  if (args.register) {
281711
- await runRegister();
282013
+ await runRegister(args);
281712
282014
  return;
281713
282015
  }
281714
282016
  if (args.auditInfra) {
@@ -281717,18 +282019,19 @@ async function connect(argv) {
281717
282019
  }
281718
282020
  printConnectRecipe(args);
281719
282021
  }
281720
- var CONNECT_USAGE = ` ablo connect \u2014 connect your database for the read path, via logical replication
282022
+ var CONNECT_USAGE = ` ablo connect \u2014 direct writes, settled by logical replication
281721
282023
 
281722
- The Electric/PowerSync/Zero model: Ablo consumes your WAL via logical replication and
281723
- never runs DDL, owns, hosts, or migrates your schema. Your app continues to own the write path.
282024
+ Ablo applies coordinated DML with a scoped writer role. WAL observes what committed,
282025
+ orders it with external changes, and confirms it. Your Postgres remains authoritative.
281724
282026
 
281725
282027
  Usage:
281726
282028
  npx ablo connect Print the exact setup SQL for your Postgres
281727
282029
  npx ablo connect --tables a,b,c Publish only these tables (default: all tables)
281728
282030
  npx ablo connect --role <name> Name the replication role (default: ablo_replicator)
281729
- npx ablo connect --check Validate DATABASE_URL is replication-ready (runs from Ablo's
281730
- infrastructure when this machine can't reach the host)
281731
- npx ablo connect --register Register DATABASE_URL so Ablo replicates it (one self-service step)
282031
+ npx ablo connect --write-role <name> Name the DML role (default: ablo_writer)
282032
+ npx ablo connect --route <route> public-allowlist | privatelink | peering | vpn
282033
+ npx ablo connect --check Validate DATABASE_URL + ABLO_WRITE_DATABASE_URL
282034
+ npx ablo connect --register Register both scoped credentials as one direct DataSource
281732
282035
  npx ablo connect --audit-infra Read-only Stage 5 audit for deprecated Ablo sync tables/types`;
281733
282036
 
281734
282037
  // src/cli/generate.ts
@@ -283993,7 +284296,7 @@ async function main() {
283993
284296
  console.log(` ${import_picocolors20.default.bold("Usage:")}`);
283994
284297
  console.log(` npx ablo init Scaffold ablo/ directory + starter schema`);
283995
284298
  console.log(` npx ablo init --yes [--framework nextjs] Non-interactive (agents/CI): no prompts, flag-driven`);
283996
- console.log(` [--auth apikey] [--storage direct|endpoint] [--project <slug>] [--no-project]`);
284299
+ console.log(` [--auth apikey] [--storage replication|endpoint] [--project <slug>] [--no-project]`);
283997
284300
  console.log(` [--no-agent] [--no-pull] [--no-install] [--no-login]`);
283998
284301
  console.log(` npx ablo login Authorize in your browser (provisions sandbox + production keys)`);
283999
284302
  console.log(` npx ablo login --project <slug> Same, scoped to a project (mints its keys, makes it active)`);
@@ -284041,7 +284344,7 @@ function bailIfCancelled(value) {
284041
284344
  }
284042
284345
  var INIT_FRAMEWORKS = ["nextjs", "vite", "remix", "vanilla"];
284043
284346
  var INIT_AUTHS = ["apikey", "firebase", "auth0", "clerk", "supabase", "betterauth", "jwt"];
284044
- var INIT_STORAGES = ["replication", "endpoint", "direct", "datasource"];
284347
+ var INIT_STORAGES = ["replication", "endpoint", "datasource"];
284045
284348
  function parseInitArgs(args) {
284046
284349
  const has = (flag2) => args.includes(flag2);
284047
284350
  const val = (flag2) => {
@@ -284173,12 +284476,6 @@ async function init(args = []) {
284173
284476
  () => Promise.resolve("replication")
284174
284477
  );
284175
284478
  const storage = storageChoice === "datasource" ? "endpoint" : storageChoice;
284176
- if (storage === "direct") {
284177
- Me(
284178
- "`--storage direct` uses the deprecated databaseUrl connector. Logical replication\n(`npx ablo connect`) is the supported path; a signed Data Source endpoint is the fallback.",
284179
- import_picocolors20.default.yellow("Deprecated")
284180
- );
284181
- }
284182
284479
  const agent = await chooseBool(
284183
284480
  opts.agent,
284184
284481
  true,
@@ -284227,7 +284524,7 @@ async function init(args = []) {
284227
284524
  }
284228
284525
  (0, import_fs12.writeFileSync)((0, import_path7.join)(abloDir, "schema.ts"), schemaSource);
284229
284526
  created.push(`${abloDir}/schema.ts${schemaNote}`);
284230
- (0, import_fs12.writeFileSync)((0, import_path7.join)(abloDir, "index.ts"), generateSyncConfig(auth, storage));
284527
+ (0, import_fs12.writeFileSync)((0, import_path7.join)(abloDir, "index.ts"), generateSyncConfig(auth));
284231
284528
  created.push(`${abloDir}/index.ts`);
284232
284529
  (0, import_fs12.writeFileSync)((0, import_path7.join)(abloDir, "register.ts"), generateRegister());
284233
284530
  created.push(`${abloDir}/register.ts`);
@@ -284300,8 +284597,6 @@ async function init(args = []) {
284300
284597
  `Connect your database \u2014 ${import_picocolors20.default.bold("npx ablo connect")} prints the one-time logical-replication setup SQL to run on your Postgres`,
284301
284598
  `Verify it \u2014 ${import_picocolors20.default.bold("npx ablo connect --check")} walks wal_level, the publication, the role, and replica identity, with the exact fix for anything missing`,
284302
284599
  `Register it \u2014 ${import_picocolors20.default.bold("npx ablo connect --register")} tells Ablo to start replicating; your app keeps writing through your own backend while Ablo tails the WAL`
284303
- ] : storage === "direct" ? [
284304
- `Provision your DB: ${import_picocolors20.default.bold("npx ablo migrate")} (creates your synced-model tables with row-level security; keep your own migrations for everything else)`
284305
284600
  ] : [
284306
284601
  `Provision your DB: ${import_picocolors20.default.bold("npx ablo migrate")} (creates your Ablo-model tables + the adapter tables; keep your own migrations for everything else), then mount ${import_picocolors20.default.bold(`${abloDir}/data-source.ts`)} at ${import_picocolors20.default.bold("/api/ablo/source")}`
284307
284602
  ],
@@ -284359,9 +284654,7 @@ export const schema = defineSchema({
284359
284654
  });
284360
284655
  `;
284361
284656
  }
284362
- function generateSyncConfig(auth, storage) {
284363
- const databaseLine = storage === "direct" ? `
284364
- databaseUrl: process.env.DATABASE_URL, // deprecated direct connector` : "";
284657
+ function generateSyncConfig(auth) {
284365
284658
  const authLine = auth === "apikey" ? "" : auth === "firebase" ? `
284366
284659
  auth: async () => {
284367
284660
  const { getAuth } = await import('firebase/auth');
@@ -284381,7 +284674,7 @@ import { schema } from './schema';
284381
284674
  // ('use client') component; the browser uses app/providers.tsx, which authenticates
284382
284675
  // via the session route and never touches the key.
284383
284676
  export const sync = Ablo({
284384
- apiKey: process.env.ABLO_API_KEY,${databaseLine}${authLine}
284677
+ apiKey: process.env.ABLO_API_KEY,${authLine}
284385
284678
  schema,
284386
284679
  });
284387
284680
 
@@ -284405,7 +284698,7 @@ export {};
284405
284698
  }
284406
284699
  function generateEnv(storage, opts = {}) {
284407
284700
  const { includeApiKey = true } = opts;
284408
- const databaseBlock = storage === "direct" ? "# DEPRECATED direct connector. The client registers this connection (sent once\n# over TLS, stored sealed) so Ablo dials into your Postgres. Prefer logical\n# replication (`npx ablo connect`); to keep the transaction log in your infra too,\n# self-host the engine. Use a dedicated non-superuser role; the browser never sees this.\nDATABASE_URL=postgres://user:password@host:5432/db\n" : storage === "replication" ? "# Used by `npx ablo connect` to set up + register logical replication \u2014 the\n# DIRECT (un-pooled) endpoint. Ablo TAILS your WAL from here; it never writes.\n# The client never sees it; the browser never sees it. Your DB stays yours.\nDATABASE_URL=postgres://user:password@host:5432/db\n" : "# Used by ablo/data-source.ts (your DB endpoint) + `ablo migrate` \u2014 NOT the client.\n# Ablo never sees it; the browser never sees it. Your DB stays in your app.\nDATABASE_URL=postgres://user:password@host:5432/db\n";
284701
+ const databaseBlock = storage === "replication" ? "# Used by `npx ablo connect` to set up + register logical replication \u2014 the\n# DIRECT (un-pooled) endpoint. Ablo TAILS your WAL from here; it never writes.\n# The client never sees it; the browser never sees it. Your DB stays yours.\nDATABASE_URL=postgres://user:password@host:5432/db\n" : "# Used by ablo/data-source.ts (your DB endpoint) + `ablo migrate` \u2014 NOT the client.\n# Ablo never sees it; the browser never sees it. Your DB stays in your app.\nDATABASE_URL=postgres://user:password@host:5432/db\n";
284409
284702
  const webhookBlock = storage === "endpoint" ? "# Signing secret for the webhook receiver (app/api/ablo/webhooks/route.ts).\n# Ablo mints this when you register the endpoint's URL (POST /v1/webhook_endpoints\n# or the dashboard) and returns it once \u2014 paste it here.\nABLO_WEBHOOK_SECRET=whsec_your_endpoint_secret_here\n" : "";
284410
284703
  const apiKeyBlock = includeApiKey ? "# Ablo Sync Engine \u2014 use a sk_test_ key for local dev (`npx ablo push`)\nABLO_API_KEY=sk_test_your_key_here\n" : "";
284411
284704
  return `${apiKeyBlock}${webhookBlock}${databaseBlock}`;