@abloatai/ablo 0.29.2 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/CHANGELOG.md +27 -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 +561 -90
  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;
@@ -280036,27 +280074,10 @@ function clearCredential() {
280036
280074
  return removed;
280037
280075
  }
280038
280076
  function resolveApiKey(modeOverride) {
280039
- if (process.env.ABLO_API_KEY) return process.env.ABLO_API_KEY;
280040
- const cfg = readConfig();
280041
- if (!cfg) return void 0;
280042
- const entry = cfg.profiles[activeProfileName(cfg)]?.[modeOverride ?? cfg.mode];
280043
- if (!entry) return void 0;
280044
- if (entry.expiresAt && Date.parse(entry.expiresAt) <= Date.now()) return void 0;
280045
- return entry.apiKey;
280077
+ return resolveKey({ purpose: "data", mode: modeOverride }).key;
280046
280078
  }
280047
280079
  function resolveOrgKey(modeOverride) {
280048
- if (process.env.ABLO_API_KEY) return process.env.ABLO_API_KEY;
280049
- const cfg = readConfig();
280050
- if (!cfg) return void 0;
280051
- const mode2 = modeOverride ?? cfg.mode;
280052
- const order = [.../* @__PURE__ */ new Set([activeProfileName(cfg), DEFAULT_PROFILE, ...Object.keys(cfg.profiles)])];
280053
- for (const name of order) {
280054
- const entry = cfg.profiles[name]?.[mode2];
280055
- if (!entry) continue;
280056
- if (entry.expiresAt && Date.parse(entry.expiresAt) <= Date.now()) continue;
280057
- return entry.apiKey;
280058
- }
280059
- return void 0;
280080
+ return resolveKey({ purpose: "org-read", mode: modeOverride }).key;
280060
280081
  }
280061
280082
  function guardActiveProjectKey() {
280062
280083
  if (process.env.ABLO_API_KEY) {
@@ -280068,11 +280089,31 @@ function guardActiveProjectKey() {
280068
280089
  const available = Object.entries(profiles).filter(([, keys]) => hasKey(keys)).map(([name]) => name);
280069
280090
  return { ok: hasKey(profiles[activeProfile]), activeProfile, available };
280070
280091
  }
280092
+ function resolveKey(policy) {
280093
+ if (policy.scanEnvFiles) {
280094
+ const fromProject = readProjectApiKey(policy.cwd);
280095
+ if (fromProject) return { key: fromProject.key, source: fromProject.source };
280096
+ } else if (process.env.ABLO_API_KEY) {
280097
+ return { key: process.env.ABLO_API_KEY, source: "env" };
280098
+ }
280099
+ const cfg = readConfig();
280100
+ if (!cfg) return { key: void 0, source: null };
280101
+ const mode2 = policy.mode ?? cfg.mode;
280102
+ const profiles = policy.purpose === "org-read" ? (
280103
+ // Active first (a scoped key is preferred when present), then the
280104
+ // org-default, then any remaining profile — deduplicated, order-preserving.
280105
+ [.../* @__PURE__ */ new Set([activeProfileName(cfg), DEFAULT_PROFILE, ...Object.keys(cfg.profiles)])]
280106
+ ) : [activeProfileName(cfg)];
280107
+ for (const name of profiles) {
280108
+ const entry = cfg.profiles[name]?.[mode2];
280109
+ if (!entry) continue;
280110
+ if (entry.expiresAt && Date.parse(entry.expiresAt) <= Date.now()) continue;
280111
+ return { key: entry.apiKey, source: "stored" };
280112
+ }
280113
+ return { key: void 0, source: null };
280114
+ }
280071
280115
  function resolveEffectiveApiKey(modeOverride, cwd) {
280072
- const fromProject = readProjectApiKey(cwd);
280073
- if (fromProject) return { key: fromProject.key, source: fromProject.source };
280074
- const key = resolveApiKey(modeOverride);
280075
- return key ? { key, source: "stored" } : { key: void 0, source: null };
280116
+ return resolveKey({ purpose: "data", mode: modeOverride, scanEnvFiles: true, cwd });
280076
280117
  }
280077
280118
  function resolvePushPlan() {
280078
280119
  const { key, source } = resolveEffectiveApiKey();
@@ -280889,6 +280930,18 @@ async function push(argv) {
280889
280930
  console.log(import_picocolors4.default.yellow(` Applied ${body.warnings.length} destructive change(s):`));
280890
280931
  for (const w2 of body.warnings) console.log(import_picocolors4.default.yellow(fmtSignal(w2)));
280891
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
+ }
280892
280945
  }
280893
280946
  return;
280894
280947
  }
@@ -281145,14 +281198,171 @@ async function migrate(argv) {
281145
281198
  // src/cli/connect.ts
281146
281199
  init_cjs_shims();
281147
281200
  var import_picocolors6 = __toESM(require_picocolors(), 1);
281201
+
281202
+ // src/cli/remoteValidation.ts
281203
+ init_cjs_shims();
281204
+ var DIAL_FAILURE_CODES = /* @__PURE__ */ new Set([
281205
+ "ENOTFOUND",
281206
+ "EAI_AGAIN",
281207
+ "ECONNREFUSED",
281208
+ "ECONNRESET",
281209
+ "ETIMEDOUT",
281210
+ "ENETUNREACH",
281211
+ "EHOSTUNREACH",
281212
+ "CONNECT_TIMEOUT"
281213
+ ]);
281214
+ function dialFailureReason(err) {
281215
+ if (err === null || typeof err !== "object") return null;
281216
+ const coded = err;
281217
+ const code = typeof coded.code === "string" ? coded.code : null;
281218
+ if (code && DIAL_FAILURE_CODES.has(code)) {
281219
+ return typeof coded.message === "string" && coded.message.length > 0 ? coded.message : code;
281220
+ }
281221
+ if (Array.isArray(coded.errors)) {
281222
+ for (const member of coded.errors) {
281223
+ const reason = dialFailureReason(member);
281224
+ if (reason) return reason;
281225
+ }
281226
+ }
281227
+ return null;
281228
+ }
281229
+ function validateEndpoint(baseUrl2) {
281230
+ return `${baseUrl2.replace(/\/+$/, "")}/api/v1/datasources/validate`;
281231
+ }
281232
+ function describeRemoteFailure(failure) {
281233
+ switch (failure.item) {
281234
+ case "wal_level":
281235
+ return {
281236
+ label: failure.actual ? `wal_level is ${failure.actual} (need logical)` : `wal_level must be logical`,
281237
+ fix: failure.fix
281238
+ };
281239
+ case "publication":
281240
+ return { label: "the Ablo publication does not exist", fix: failure.fix };
281241
+ case "replication_role":
281242
+ return { label: "the DATABASE_URL role lacks the REPLICATION attribute", fix: failure.fix };
281243
+ case "replica_identity":
281244
+ return {
281245
+ label: `published tables cannot replicate UPDATE/DELETE${failure.actual ? ` (${failure.actual})` : ""}`,
281246
+ fix: failure.fix
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 };
281263
+ default:
281264
+ return { label: failure.item, fix: failure.fix };
281265
+ }
281266
+ }
281267
+ function parseWireFailures(value) {
281268
+ if (!Array.isArray(value)) return [];
281269
+ const failures = [];
281270
+ for (const entry of value) {
281271
+ if (entry === null || typeof entry !== "object") continue;
281272
+ const { item, actual, fix } = entry;
281273
+ if (typeof item !== "string" || typeof fix !== "string") continue;
281274
+ failures.push({ item, fix, ...typeof actual === "string" ? { actual } : {} });
281275
+ }
281276
+ return failures;
281277
+ }
281278
+ async function requestRemoteValidation(input) {
281279
+ const doFetch = input.fetchImpl ?? fetch;
281280
+ let res;
281281
+ try {
281282
+ res = await doFetch(validateEndpoint(input.apiUrl), {
281283
+ method: "POST",
281284
+ headers: { "content-type": "application/json", authorization: `Bearer ${input.apiKey}` },
281285
+ body: JSON.stringify({
281286
+ connectionString: input.connectionString,
281287
+ ...input.writeConnectionString ? { writeConnectionString: input.writeConnectionString } : {}
281288
+ })
281289
+ });
281290
+ } catch (err) {
281291
+ return {
281292
+ ok: false,
281293
+ status: 0,
281294
+ message: `couldn't reach ${input.apiUrl}: ${err instanceof Error ? err.message : String(err)}`
281295
+ };
281296
+ }
281297
+ const body = await res.json().catch(() => ({}));
281298
+ if (!res.ok) {
281299
+ const code = typeof body.code === "string" ? body.code : body.error?.code;
281300
+ const message = typeof body.message === "string" ? body.message : typeof body.error?.message === "string" ? body.error.message : `HTTP ${res.status}`;
281301
+ return {
281302
+ ok: false,
281303
+ status: res.status,
281304
+ message,
281305
+ ...typeof code === "string" ? { code } : {}
281306
+ };
281307
+ }
281308
+ const reachable = body.reachable === true;
281309
+ return {
281310
+ ok: true,
281311
+ reachable,
281312
+ ready: body.ready === true,
281313
+ ...typeof body.reason === "string" ? { reason: body.reason } : {},
281314
+ failures: parseWireFailures(body.failures)
281315
+ };
281316
+ }
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
+
281348
+ // src/cli/connect.ts
281148
281349
  var ABLO_PUBLICATION = "ablo_publication";
281149
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
+ ];
281150
281358
  function parseConnectArgs(argv) {
281151
281359
  let check2 = false;
281152
281360
  let register = false;
281153
281361
  let auditInfra = false;
281154
281362
  let tables = [];
281155
281363
  let role = ABLO_REPLICATION_ROLE;
281364
+ let writeRole = ABLO_WRITE_ROLE;
281365
+ let route = "public-allowlist";
281156
281366
  for (let i = 0; i < argv.length; i++) {
281157
281367
  const arg = argv[i];
281158
281368
  switch (arg) {
@@ -281173,19 +281383,41 @@ function parseConnectArgs(argv) {
281173
281383
  case "--role":
281174
281384
  role = argv[++i] ?? role;
281175
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
+ }
281176
281400
  default:
281177
281401
  throw new AbloValidationError(`unknown flag: ${arg}`, { code: "cli_invalid_arguments" });
281178
281402
  }
281179
281403
  }
281180
- 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 };
281181
281410
  }
281182
281411
  function quoteIdent(id) {
281183
281412
  return `"${id.replace(/"/g, '""')}"`;
281184
281413
  }
281185
281414
  function connectSetupSql(input) {
281186
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;
281187
281417
  const tables = input.tables ?? [];
281188
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);
281189
281421
  return [
281190
281422
  // 1. Turn on logical decoding. Requires a restart (it's not reloadable).
281191
281423
  `ALTER SYSTEM SET wal_level = 'logical';`,
@@ -281195,18 +281427,58 @@ function connectSetupSql(input) {
281195
281427
  `CREATE ROLE ${quoteIdent(role)} WITH REPLICATION LOGIN PASSWORD '<password>';`,
281196
281428
  `GRANT SELECT ON ALL TABLES IN SCHEMA public TO ${quoteIdent(role)};`,
281197
281429
  // Future tables get SELECT automatically, so the publication doesn't outgrow the grant.
281198
- `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 $$;`
281199
281466
  ];
281200
281467
  }
281201
281468
  function printConnectRecipe(args) {
281202
- 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
+ });
281203
281474
  console.log(`
281204
- ${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")}
281205
281476
  `);
281206
281477
  console.log(
281207
- ` Ablo consumes your write-ahead log (WAL) via logical replication and ${import_picocolors6.default.bold("never")} runs DDL,
281208
- owns, hosts, or migrates your schema. Your app continues to own the write path \u2014 Ablo tails
281209
- 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)")}:
281210
281482
  `
281211
281483
  );
281212
281484
  console.log(` ${import_picocolors6.default.bold("1.")} Enable logical decoding ${import_picocolors6.default.dim("(then RESTART Postgres \u2014 wal_level is not reloadable)")}`);
@@ -281224,7 +281496,7 @@ function printConnectRecipe(args) {
281224
281496
  console.log(import_picocolors6.default.dim(` (Scope it with ${import_picocolors6.default.bold("ablo connect --tables a,b,c")} to publish a subset.)`));
281225
281497
  }
281226
281498
  console.log(`
281227
- ${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)")}`);
281228
281500
  console.log(` ${import_picocolors6.default.cyan(sql[2])}`);
281229
281501
  console.log(` ${import_picocolors6.default.cyan(sql[3])}`);
281230
281502
  console.log(` ${import_picocolors6.default.cyan(sql[4])}`);
@@ -281236,15 +281508,31 @@ function printConnectRecipe(args) {
281236
281508
  );
281237
281509
  console.log(
281238
281510
  `
281239
- ${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)`)}
281240
281528
  ${import_picocolors6.default.cyan("npx ablo connect --check")}
281241
281529
  `
281242
281530
  );
281243
281531
  console.log(
281244
281532
  import_picocolors6.default.dim(
281245
- ` Reminder: this is the READ path \u2014 Ablo tails your WAL and never writes to your database.
281246
- Your writes keep going through your own backend. (Ablo HOSTING your rows, or dialing in via
281247
- ${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.`
281248
281536
  )
281249
281537
  );
281250
281538
  console.log();
@@ -281345,6 +281633,76 @@ On RDS: GRANT rds_replication TO <your_role>;`
281345
281633
  }
281346
281634
  return items;
281347
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
+ }
281348
281706
  async function auditTenantSyncInfra(sql) {
281349
281707
  const artifacts = [];
281350
281708
  for (const name of SYNC_INFRA_RELATIONS) {
@@ -281373,31 +281731,125 @@ function requireDatabaseUrl(verb) {
281373
281731
  }
281374
281732
  return dbUrl;
281375
281733
  }
281376
- async function probeAndReport(dbUrl) {
281377
- const sql = src_default(dbUrl, { max: 1, prepare: false, onnotice: () => {
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") {
281747
+ const sql = src_default(dbUrl, { max: 1, prepare: false, connect_timeout: 10, onnotice: () => {
281378
281748
  } });
281379
281749
  let items;
281380
281750
  try {
281381
- items = await probeReadiness(sql);
281751
+ items = kind === "replication" ? await probeReadiness(sql) : await probeDirectWriteReadiness(sql);
281382
281752
  } catch (err) {
281753
+ await sql.end({ timeout: 2 }).catch(() => void 0);
281754
+ const dial = dialFailureReason(err);
281755
+ if (dial) return { kind: "no-dial", reason: dial };
281383
281756
  const pg = err ?? {};
281384
281757
  console.error(import_picocolors6.default.red(` Couldn't read the database: ${pg.message ?? String(err)}`));
281385
- await sql.end({ timeout: 2 });
281386
281758
  process.exit(1);
281387
281759
  }
281388
281760
  await sql.end({ timeout: 2 });
281389
281761
  for (const item of items) printCheckItem(item);
281390
- return items.filter((i) => !i.ok).length;
281762
+ return { kind: "probed", failures: items.filter((i) => !i.ok).length };
281763
+ }
281764
+ async function runRemoteCheck(dbUrl, writeDbUrl, localReason) {
281765
+ console.log(
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
281769
+ `
281770
+ );
281771
+ const apiKey = resolveApiKey();
281772
+ if (!apiKey) {
281773
+ console.error(
281774
+ import_picocolors6.default.red(` The engine-side check needs an API key, and none was found.`) + import_picocolors6.default.dim(
281775
+ ` Run ${import_picocolors6.default.bold("ablo login")} (or set ${import_picocolors6.default.bold("ABLO_API_KEY")}), then re-run ${import_picocolors6.default.bold("ablo connect --check")}.`
281776
+ )
281777
+ );
281778
+ process.exit(1);
281779
+ }
281780
+ const apiUrl2 = (process.env.ABLO_API_URL ?? DEFAULT_URL).replace(/\/+$/, "");
281781
+ const result = await requestRemoteValidation({
281782
+ apiUrl: apiUrl2,
281783
+ apiKey,
281784
+ connectionString: dbUrl,
281785
+ writeConnectionString: writeDbUrl
281786
+ });
281787
+ if (!result.ok) {
281788
+ console.error(import_picocolors6.default.red(` The engine-side check failed: ${result.message}`));
281789
+ if (result.code === "forbidden") {
281790
+ console.error(
281791
+ import_picocolors6.default.dim(` Checking a database from Ablo's side needs a ${import_picocolors6.default.bold("secret")} key (sk_\u2026). Run ${import_picocolors6.default.bold("ablo login")} for one.`)
281792
+ );
281793
+ }
281794
+ console.error();
281795
+ process.exit(1);
281796
+ }
281797
+ if (!result.reachable) {
281798
+ console.error(
281799
+ ` ${import_picocolors6.default.red("\u2717")} Ablo's infrastructure can't reach both direct connections${result.reason ? ` ${import_picocolors6.default.dim(`(${result.reason})`)}` : ""}.`
281800
+ );
281801
+ console.error(
281802
+ import_picocolors6.default.dim(
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.
281805
+ `
281806
+ )
281807
+ );
281808
+ process.exit(1);
281809
+ }
281810
+ for (const failure of result.failures) {
281811
+ const { label, fix } = describeRemoteFailure(failure);
281812
+ printCheckItem({ ok: false, label, fix });
281813
+ }
281814
+ console.log();
281815
+ if (result.ready) {
281816
+ console.log(
281817
+ ` ${import_picocolors6.default.green("\u2713")} Ready \u2014 checked from Ablo's infrastructure. Both direct DML and WAL settlement are available.
281818
+ `
281819
+ );
281820
+ process.exit(0);
281821
+ }
281822
+ const count = result.failures.length;
281823
+ console.log(
281824
+ ` ${import_picocolors6.default.red(`${count} item${count === 1 ? "" : "s"} to fix`)} ${import_picocolors6.default.dim(`\u2014 found by Ablo's infrastructure. Apply the fixes above, then re-run ${import_picocolors6.default.bold("ablo connect --check")}.`)}
281825
+ `
281826
+ );
281827
+ process.exit(1);
281391
281828
  }
281392
281829
  async function runCheck() {
281393
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");
281394
281838
  console.log(`
281395
- ${brand("ablo")} ${import_picocolors6.default.dim("connect --check")} ${import_picocolors6.default.dim("logical-replication readiness")}
281839
+ ${import_picocolors6.default.bold("Direct-write role")}
281396
281840
  `);
281397
- const failures = await probeAndReport(dbUrl);
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("; "));
281848
+ }
281849
+ const failures = replication.failures + write.failures;
281398
281850
  console.log();
281399
281851
  if (failures === 0) {
281400
- 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.
281401
281853
  `);
281402
281854
  process.exit(0);
281403
281855
  }
@@ -281410,8 +281862,9 @@ async function runCheck() {
281410
281862
  function registerEndpoint(baseUrl2) {
281411
281863
  return `${baseUrl2.replace(/\/+$/, "")}/api/v1/datasources`;
281412
281864
  }
281413
- async function runRegister() {
281865
+ async function runRegister(args) {
281414
281866
  const dbUrl = requireDatabaseUrl("--register");
281867
+ const writeDbUrl = requireWriteDatabaseUrl("--register");
281415
281868
  const apiKey = resolveApiKey();
281416
281869
  if (!apiKey) {
281417
281870
  console.error(
@@ -281420,13 +281873,32 @@ async function runRegister() {
281420
281873
  process.exit(1);
281421
281874
  }
281422
281875
  console.log(`
281423
- ${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")}
281424
281877
  `);
281425
- const failures = await probeAndReport(dbUrl);
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")}
281883
+ `);
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) {
281891
+ console.log(
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.
281895
+ `
281896
+ );
281897
+ }
281426
281898
  if (failures > 0) {
281427
281899
  console.log(
281428
281900
  `
281429
- ${import_picocolors6.default.red(`${failures} item${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.")}
281430
281902
  `
281431
281903
  );
281432
281904
  process.exit(1);
@@ -281437,7 +281909,12 @@ async function runRegister() {
281437
281909
  res = await fetch(registerEndpoint(apiUrl2), {
281438
281910
  method: "POST",
281439
281911
  headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
281440
- body: JSON.stringify({ connectionString: dbUrl })
281912
+ body: JSON.stringify({
281913
+ connection: "direct",
281914
+ connectionString: dbUrl,
281915
+ writeConnectionString: writeDbUrl,
281916
+ route: args.route
281917
+ })
281441
281918
  });
281442
281919
  } catch (err) {
281443
281920
  console.error(import_picocolors6.default.red(`
@@ -281447,9 +281924,11 @@ async function runRegister() {
281447
281924
  }
281448
281925
  if (res.ok) {
281449
281926
  const body2 = await res.json().catch(() => ({}));
281927
+ const statusNote = body2.status === "active" ? `${args.route}, active` : args.route;
281450
281928
  console.log(
281451
281929
  `
281452
- ${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.
281453
281932
  `
281454
281933
  );
281455
281934
  process.exit(0);
@@ -281465,20 +281944,20 @@ async function runRegister() {
281465
281944
  console.error(
281466
281945
  import_picocolors6.default.dim(` This deployment can\u2019t accept connection strings \u2014 use a self-hosted/hosted engine, or the signed endpoint fallback.`)
281467
281946
  );
281468
- } else if (code === "database_not_replication_ready") {
281947
+ } else if (code === "database_not_replication_ready" || code === "data_source_blocked") {
281469
281948
  for (const f of body.details?.failures ?? []) {
281470
281949
  console.error(` ${import_picocolors6.default.red("\u2717")} ${import_picocolors6.default.bold(f.item ?? "item")}${f.actual ? import_picocolors6.default.dim(` (${f.actual})`) : ""}`);
281471
281950
  if (f.fix) for (const line of f.fix.split("\n")) console.error(` ${import_picocolors6.default.red("\u2022")} ${line}`);
281472
281951
  }
281473
281952
  console.error(import_picocolors6.default.dim(`
281474
281953
  Apply the fixes, verify with ${import_picocolors6.default.bold("ablo connect --check")}, then re-run.`));
281475
- } else if (code === "database_unreachable") {
281954
+ } else if (code === "database_unreachable" || code === "source_unreachable") {
281476
281955
  if (body.details?.reason) console.error(import_picocolors6.default.dim(` ${body.details.reason}`));
281477
281956
  console.error(
281478
281957
  import_picocolors6.default.dim(
281479
281958
  ` Ablo's servers must be able to reach this database \u2014 a localhost or private-network
281480
- Postgres can't be replicated by the hosted service. Use a reachable host (or a tunnel),
281481
- 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.`
281482
281961
  )
281483
281962
  );
281484
281963
  }
@@ -281531,7 +282010,7 @@ async function connect(argv) {
281531
282010
  return;
281532
282011
  }
281533
282012
  if (args.register) {
281534
- await runRegister();
282013
+ await runRegister(args);
281535
282014
  return;
281536
282015
  }
281537
282016
  if (args.auditInfra) {
@@ -281540,17 +282019,19 @@ async function connect(argv) {
281540
282019
  }
281541
282020
  printConnectRecipe(args);
281542
282021
  }
281543
- 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
281544
282023
 
281545
- The Electric/PowerSync/Zero model: Ablo consumes your WAL via logical replication and
281546
- 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.
281547
282026
 
281548
282027
  Usage:
281549
282028
  npx ablo connect Print the exact setup SQL for your Postgres
281550
282029
  npx ablo connect --tables a,b,c Publish only these tables (default: all tables)
281551
282030
  npx ablo connect --role <name> Name the replication role (default: ablo_replicator)
281552
- npx ablo connect --check Validate DATABASE_URL is replication-ready
281553
- 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
281554
282035
  npx ablo connect --audit-infra Read-only Stage 5 audit for deprecated Ablo sync tables/types`;
281555
282036
 
281556
282037
  // src/cli/generate.ts
@@ -283815,7 +284296,7 @@ async function main() {
283815
284296
  console.log(` ${import_picocolors20.default.bold("Usage:")}`);
283816
284297
  console.log(` npx ablo init Scaffold ablo/ directory + starter schema`);
283817
284298
  console.log(` npx ablo init --yes [--framework nextjs] Non-interactive (agents/CI): no prompts, flag-driven`);
283818
- console.log(` [--auth apikey] [--storage direct|endpoint] [--project <slug>] [--no-project]`);
284299
+ console.log(` [--auth apikey] [--storage replication|endpoint] [--project <slug>] [--no-project]`);
283819
284300
  console.log(` [--no-agent] [--no-pull] [--no-install] [--no-login]`);
283820
284301
  console.log(` npx ablo login Authorize in your browser (provisions sandbox + production keys)`);
283821
284302
  console.log(` npx ablo login --project <slug> Same, scoped to a project (mints its keys, makes it active)`);
@@ -283863,7 +284344,7 @@ function bailIfCancelled(value) {
283863
284344
  }
283864
284345
  var INIT_FRAMEWORKS = ["nextjs", "vite", "remix", "vanilla"];
283865
284346
  var INIT_AUTHS = ["apikey", "firebase", "auth0", "clerk", "supabase", "betterauth", "jwt"];
283866
- var INIT_STORAGES = ["replication", "endpoint", "direct", "datasource"];
284347
+ var INIT_STORAGES = ["replication", "endpoint", "datasource"];
283867
284348
  function parseInitArgs(args) {
283868
284349
  const has = (flag2) => args.includes(flag2);
283869
284350
  const val = (flag2) => {
@@ -283995,12 +284476,6 @@ async function init(args = []) {
283995
284476
  () => Promise.resolve("replication")
283996
284477
  );
283997
284478
  const storage = storageChoice === "datasource" ? "endpoint" : storageChoice;
283998
- if (storage === "direct") {
283999
- Me(
284000
- "`--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.",
284001
- import_picocolors20.default.yellow("Deprecated")
284002
- );
284003
- }
284004
284479
  const agent = await chooseBool(
284005
284480
  opts.agent,
284006
284481
  true,
@@ -284049,7 +284524,7 @@ async function init(args = []) {
284049
284524
  }
284050
284525
  (0, import_fs12.writeFileSync)((0, import_path7.join)(abloDir, "schema.ts"), schemaSource);
284051
284526
  created.push(`${abloDir}/schema.ts${schemaNote}`);
284052
- (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));
284053
284528
  created.push(`${abloDir}/index.ts`);
284054
284529
  (0, import_fs12.writeFileSync)((0, import_path7.join)(abloDir, "register.ts"), generateRegister());
284055
284530
  created.push(`${abloDir}/register.ts`);
@@ -284122,8 +284597,6 @@ async function init(args = []) {
284122
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`,
284123
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`,
284124
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`
284125
- ] : storage === "direct" ? [
284126
- `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)`
284127
284600
  ] : [
284128
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")}`
284129
284602
  ],
@@ -284181,9 +284654,7 @@ export const schema = defineSchema({
284181
284654
  });
284182
284655
  `;
284183
284656
  }
284184
- function generateSyncConfig(auth, storage) {
284185
- const databaseLine = storage === "direct" ? `
284186
- databaseUrl: process.env.DATABASE_URL, // deprecated direct connector` : "";
284657
+ function generateSyncConfig(auth) {
284187
284658
  const authLine = auth === "apikey" ? "" : auth === "firebase" ? `
284188
284659
  auth: async () => {
284189
284660
  const { getAuth } = await import('firebase/auth');
@@ -284203,7 +284674,7 @@ import { schema } from './schema';
284203
284674
  // ('use client') component; the browser uses app/providers.tsx, which authenticates
284204
284675
  // via the session route and never touches the key.
284205
284676
  export const sync = Ablo({
284206
- apiKey: process.env.ABLO_API_KEY,${databaseLine}${authLine}
284677
+ apiKey: process.env.ABLO_API_KEY,${authLine}
284207
284678
  schema,
284208
284679
  });
284209
284680
 
@@ -284227,7 +284698,7 @@ export {};
284227
284698
  }
284228
284699
  function generateEnv(storage, opts = {}) {
284229
284700
  const { includeApiKey = true } = opts;
284230
- 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";
284231
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" : "";
284232
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" : "";
284233
284704
  return `${apiKeyBlock}${webhookBlock}${databaseBlock}`;