@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.
- package/CHANGELOG.md +27 -0
- package/README.md +3 -2
- package/dist/BaseSyncedStore.js +3 -1
- package/dist/Database.js +29 -2
- package/dist/SyncClient.d.ts +5 -3
- package/dist/SyncClient.js +8 -4
- package/dist/ai-sdk/coordinatedTool.d.ts +0 -1
- package/dist/ai-sdk/coordinatedTool.js +0 -1
- package/dist/ai-sdk/coordinationContext.js +1 -5
- package/dist/cli.cjs +561 -90
- package/dist/client/Ablo.d.ts +1 -1
- package/dist/client/Ablo.js +24 -36
- package/dist/client/auth.d.ts +8 -18
- package/dist/client/auth.js +16 -96
- package/dist/client/createModelProxy.d.ts +16 -11
- package/dist/client/createModelProxy.js +162 -20
- package/dist/client/httpClient.d.ts +1 -1
- package/dist/client/httpTransport.js +263 -89
- package/dist/client/options.d.ts +0 -15
- package/dist/client/resourceTypes.d.ts +13 -26
- package/dist/client/schemaConfig.js +5 -0
- package/dist/client/writeOptionsSchema.d.ts +1 -0
- package/dist/client/writeOptionsSchema.js +11 -2
- package/dist/client/wsMutationExecutor.d.ts +3 -5
- package/dist/coordination/schema.d.ts +17 -13
- package/dist/coordination/schema.js +36 -10
- package/dist/errorCodes.d.ts +9 -4
- package/dist/errorCodes.js +10 -5
- package/dist/errors.js +4 -9
- package/dist/index.d.ts +4 -0
- package/dist/interfaces/index.d.ts +31 -19
- package/dist/schema/index.d.ts +1 -1
- package/dist/schema/index.js +4 -1
- package/dist/schema/schema.d.ts +13 -0
- package/dist/schema/select.js +7 -0
- package/dist/schema/syncDeltaRow.d.ts +31 -13
- package/dist/schema/syncDeltaRow.js +33 -15
- package/dist/schema/tenancy.d.ts +52 -16
- package/dist/schema/tenancy.js +86 -25
- package/dist/server/commit.d.ts +8 -24
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +1 -0
- package/dist/server/readConfig.d.ts +10 -0
- package/dist/source/adapter.d.ts +26 -15
- package/dist/source/adapter.js +5 -3
- package/dist/source/adapters/drizzle.js +35 -9
- package/dist/source/adapters/kysely.d.ts +33 -61
- package/dist/source/adapters/kysely.js +170 -177
- package/dist/source/adapters/kyselyMutationCore.d.ts +76 -0
- package/dist/source/adapters/kyselyMutationCore.js +125 -0
- package/dist/source/adapters/memory.d.ts +1 -1
- package/dist/source/adapters/memory.js +24 -10
- package/dist/source/adapters/prisma.js +35 -8
- package/dist/source/conformance.d.ts +26 -28
- package/dist/source/conformance.js +142 -69
- package/dist/source/contract.d.ts +46 -4
- package/dist/source/contract.js +66 -5
- package/dist/source/factory.d.ts +3 -3
- package/dist/source/factory.js +24 -3
- package/dist/source/idempotency.d.ts +46 -0
- package/dist/source/idempotency.js +129 -0
- package/dist/source/index.d.ts +6 -4
- package/dist/source/index.js +5 -3
- package/dist/source/migrations.d.ts +9 -5
- package/dist/source/migrations.js +39 -6
- package/dist/source/types.d.ts +88 -14
- package/dist/source/types.js +16 -0
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +0 -1
- package/dist/sync/BootstrapFetcher.js +7 -0
- package/dist/sync/SyncWebSocket.d.ts +5 -27
- package/dist/sync/awaitClaimGrant.d.ts +6 -0
- package/dist/sync/awaitClaimGrant.js +12 -2
- package/dist/sync/commitFrames.d.ts +2 -4
- package/dist/sync/commitFrames.js +1 -0
- package/dist/sync/createClaimStream.d.ts +1 -1
- package/dist/sync/createClaimStream.js +13 -15
- package/dist/sync/deltaPipeline.d.ts +1 -1
- package/dist/sync/deltaPipeline.js +1 -1
- package/dist/sync/participants.d.ts +2 -3
- package/dist/sync/participants.js +2 -2
- package/dist/sync/wsFrameHandlers.js +35 -15
- package/dist/testing/mocks/MockMutationExecutor.d.ts +21 -0
- package/dist/testing/mocks/MockMutationExecutor.js +53 -1
- package/dist/transactions/TransactionQueue.d.ts +61 -9
- package/dist/transactions/TransactionQueue.js +502 -114
- package/dist/transactions/commitEnvelope.d.ts +4 -0
- package/dist/transactions/commitEnvelope.js +25 -0
- package/dist/transactions/commitPayload.d.ts +23 -5
- package/dist/transactions/commitPayload.js +3 -0
- package/dist/transactions/deltaConfirmation.d.ts +2 -1
- package/dist/transactions/deltaConfirmation.js +22 -4
- package/dist/transactions/durableWriteStore.d.ts +8 -1
- package/dist/transactions/httpCommitEnvelope.d.ts +3 -1
- package/dist/transactions/httpCommitEnvelope.js +30 -0
- package/dist/transactions/replayValidation.d.ts +4 -0
- package/dist/transactions/replayValidation.js +6 -0
- package/dist/types/streams.d.ts +29 -17
- package/dist/wire/commit.d.ts +671 -0
- package/dist/wire/commit.js +300 -0
- package/dist/wire/delta.d.ts +4 -0
- package/dist/wire/delta.js +9 -0
- package/dist/wire/frames.d.ts +17 -45
- package/dist/wire/index.d.ts +2 -0
- package/dist/wire/index.js +4 -0
- package/docs/agent-messaging.md +4 -5
- package/docs/agents.md +1 -2
- package/docs/api-keys.md +1 -2
- package/docs/client-behavior.md +4 -6
- package/docs/coordination.md +64 -9
- package/docs/data-sources.md +4 -7
- package/docs/integration-guide.md +6 -8
- package/docs/migration.md +0 -3
- package/docs/quickstart.md +7 -8
- package/docs/schema-contract.md +7 -8
- package/package.json +4 -4
- package/dist/client/registerDataSource.d.ts +0 -19
- 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
|
|
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
|
-
/**
|
|
277223
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
277283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
279773
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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("
|
|
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
|
|
281208
|
-
|
|
281209
|
-
|
|
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
|
|
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.")}
|
|
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
|
-
`
|
|
281246
|
-
|
|
281247
|
-
|
|
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
|
-
|
|
281377
|
-
const
|
|
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
|
-
${
|
|
281839
|
+
${import_picocolors6.default.bold("Direct-write role")}
|
|
281396
281840
|
`);
|
|
281397
|
-
const
|
|
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
|
|
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
|
|
281876
|
+
${brand("ablo")} ${import_picocolors6.default.dim("connect --register")} ${import_picocolors6.default.dim("register a direct DataSource")}
|
|
281424
281877
|
`);
|
|
281425
|
-
|
|
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
|
|
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({
|
|
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})`)}` : ""}
|
|
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
|
|
281481
|
-
|
|
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
|
|
282022
|
+
var CONNECT_USAGE = ` ablo connect \u2014 direct writes, settled by logical replication
|
|
281544
282023
|
|
|
281545
|
-
|
|
281546
|
-
|
|
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 --
|
|
281553
|
-
npx ablo connect --
|
|
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
|
|
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", "
|
|
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
|
|
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
|
|
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,${
|
|
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 === "
|
|
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}`;
|