@abloatai/ablo 0.29.3 → 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 +21 -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 +377 -84
- 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;
|
|
@@ -280892,6 +280930,18 @@ async function push(argv) {
|
|
|
280892
280930
|
console.log(import_picocolors4.default.yellow(` Applied ${body.warnings.length} destructive change(s):`));
|
|
280893
280931
|
for (const w2 of body.warnings) console.log(import_picocolors4.default.yellow(fmtSignal(w2)));
|
|
280894
280932
|
}
|
|
280933
|
+
if (body.provisioningDeferred === true) {
|
|
280934
|
+
console.log(
|
|
280935
|
+
import_picocolors4.default.yellow(
|
|
280936
|
+
` Your schema is registered, but its tables were not created on this plane \u2014 the engine's runtime role does not run DDL here.`
|
|
280937
|
+
)
|
|
280938
|
+
);
|
|
280939
|
+
console.log(
|
|
280940
|
+
import_picocolors4.default.dim(
|
|
280941
|
+
` New models are recorded as metadata; provision their tables out-of-band before you read or write them.`
|
|
280942
|
+
)
|
|
280943
|
+
);
|
|
280944
|
+
}
|
|
280895
280945
|
}
|
|
280896
280946
|
return;
|
|
280897
280947
|
}
|
|
@@ -281195,6 +281245,21 @@ function describeRemoteFailure(failure) {
|
|
|
281195
281245
|
label: `published tables cannot replicate UPDATE/DELETE${failure.actual ? ` (${failure.actual})` : ""}`,
|
|
281196
281246
|
fix: failure.fix
|
|
281197
281247
|
};
|
|
281248
|
+
case "write_role":
|
|
281249
|
+
return { label: "ABLO_WRITE_DATABASE_URL is not a scoped DML role", fix: failure.fix };
|
|
281250
|
+
case "row_security":
|
|
281251
|
+
return { label: "the direct-write role does not enforce row_security", fix: failure.fix };
|
|
281252
|
+
case "schema_privileges":
|
|
281253
|
+
return { label: "the direct-write role has unsafe schema privileges", fix: failure.fix };
|
|
281254
|
+
case "idempotency_ledger":
|
|
281255
|
+
return { label: "ablo_idempotency is missing or has unsafe grants", fix: failure.fix };
|
|
281256
|
+
case "table_privileges":
|
|
281257
|
+
return {
|
|
281258
|
+
label: `the direct-write role lacks application DML${failure.actual ? ` (${failure.actual})` : ""}`,
|
|
281259
|
+
fix: failure.fix
|
|
281260
|
+
};
|
|
281261
|
+
case "logical_marker":
|
|
281262
|
+
return { label: "the direct-write role cannot emit the correlation marker", fix: failure.fix };
|
|
281198
281263
|
default:
|
|
281199
281264
|
return { label: failure.item, fix: failure.fix };
|
|
281200
281265
|
}
|
|
@@ -281217,7 +281282,10 @@ async function requestRemoteValidation(input) {
|
|
|
281217
281282
|
res = await doFetch(validateEndpoint(input.apiUrl), {
|
|
281218
281283
|
method: "POST",
|
|
281219
281284
|
headers: { "content-type": "application/json", authorization: `Bearer ${input.apiKey}` },
|
|
281220
|
-
body: JSON.stringify({
|
|
281285
|
+
body: JSON.stringify({
|
|
281286
|
+
connectionString: input.connectionString,
|
|
281287
|
+
...input.writeConnectionString ? { writeConnectionString: input.writeConnectionString } : {}
|
|
281288
|
+
})
|
|
281221
281289
|
});
|
|
281222
281290
|
} catch (err) {
|
|
281223
281291
|
return {
|
|
@@ -281247,15 +281315,54 @@ async function requestRemoteValidation(input) {
|
|
|
281247
281315
|
};
|
|
281248
281316
|
}
|
|
281249
281317
|
|
|
281318
|
+
// src/source/migrations.ts
|
|
281319
|
+
init_cjs_shims();
|
|
281320
|
+
function idempotencyLedgerMigrations() {
|
|
281321
|
+
return [
|
|
281322
|
+
{
|
|
281323
|
+
name: "ablo_idempotency",
|
|
281324
|
+
up: `CREATE TABLE IF NOT EXISTS ablo_idempotency (
|
|
281325
|
+
client_tx_id TEXT PRIMARY KEY,
|
|
281326
|
+
response JSONB NOT NULL,
|
|
281327
|
+
request_hash TEXT,
|
|
281328
|
+
expires_at TIMESTAMPTZ NOT NULL DEFAULT 'infinity',
|
|
281329
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
281330
|
+
);`
|
|
281331
|
+
},
|
|
281332
|
+
{
|
|
281333
|
+
name: "ablo_idempotency_request_hash",
|
|
281334
|
+
// Nullable only for rows created by older adapter versions. New writes
|
|
281335
|
+
// always populate it; replaying a legacy NULL row fails closed because
|
|
281336
|
+
// the adapter cannot prove that the intent matches.
|
|
281337
|
+
up: `ALTER TABLE ablo_idempotency
|
|
281338
|
+
ADD COLUMN IF NOT EXISTS request_hash TEXT;`
|
|
281339
|
+
},
|
|
281340
|
+
{
|
|
281341
|
+
name: "ablo_idempotency_permanent_retention",
|
|
281342
|
+
up: `ALTER TABLE ablo_idempotency
|
|
281343
|
+
ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ NOT NULL DEFAULT 'infinity';`
|
|
281344
|
+
}
|
|
281345
|
+
];
|
|
281346
|
+
}
|
|
281347
|
+
|
|
281250
281348
|
// src/cli/connect.ts
|
|
281251
281349
|
var ABLO_PUBLICATION = "ablo_publication";
|
|
281252
281350
|
var ABLO_REPLICATION_ROLE = "ablo_replicator";
|
|
281351
|
+
var ABLO_WRITE_ROLE = "ablo_writer";
|
|
281352
|
+
var DIRECT_DATA_SOURCE_ROUTES = [
|
|
281353
|
+
"public-allowlist",
|
|
281354
|
+
"privatelink",
|
|
281355
|
+
"peering",
|
|
281356
|
+
"vpn"
|
|
281357
|
+
];
|
|
281253
281358
|
function parseConnectArgs(argv) {
|
|
281254
281359
|
let check2 = false;
|
|
281255
281360
|
let register = false;
|
|
281256
281361
|
let auditInfra = false;
|
|
281257
281362
|
let tables = [];
|
|
281258
281363
|
let role = ABLO_REPLICATION_ROLE;
|
|
281364
|
+
let writeRole = ABLO_WRITE_ROLE;
|
|
281365
|
+
let route = "public-allowlist";
|
|
281259
281366
|
for (let i = 0; i < argv.length; i++) {
|
|
281260
281367
|
const arg = argv[i];
|
|
281261
281368
|
switch (arg) {
|
|
@@ -281276,19 +281383,41 @@ function parseConnectArgs(argv) {
|
|
|
281276
281383
|
case "--role":
|
|
281277
281384
|
role = argv[++i] ?? role;
|
|
281278
281385
|
break;
|
|
281386
|
+
case "--write-role":
|
|
281387
|
+
writeRole = argv[++i] ?? writeRole;
|
|
281388
|
+
break;
|
|
281389
|
+
case "--route": {
|
|
281390
|
+
const value = argv[++i] ?? "";
|
|
281391
|
+
if (!DIRECT_DATA_SOURCE_ROUTES.includes(value)) {
|
|
281392
|
+
throw new AbloValidationError(
|
|
281393
|
+
`invalid direct route: ${value || "(missing)"} (expected ${DIRECT_DATA_SOURCE_ROUTES.join(", ")})`,
|
|
281394
|
+
{ code: "cli_invalid_arguments" }
|
|
281395
|
+
);
|
|
281396
|
+
}
|
|
281397
|
+
route = value;
|
|
281398
|
+
break;
|
|
281399
|
+
}
|
|
281279
281400
|
default:
|
|
281280
281401
|
throw new AbloValidationError(`unknown flag: ${arg}`, { code: "cli_invalid_arguments" });
|
|
281281
281402
|
}
|
|
281282
281403
|
}
|
|
281283
|
-
|
|
281404
|
+
if (role === writeRole) {
|
|
281405
|
+
throw new AbloValidationError("replication and write roles must be different", {
|
|
281406
|
+
code: "cli_invalid_arguments"
|
|
281407
|
+
});
|
|
281408
|
+
}
|
|
281409
|
+
return { check: check2, register, auditInfra, tables, role, writeRole, route };
|
|
281284
281410
|
}
|
|
281285
281411
|
function quoteIdent(id) {
|
|
281286
281412
|
return `"${id.replace(/"/g, '""')}"`;
|
|
281287
281413
|
}
|
|
281288
281414
|
function connectSetupSql(input) {
|
|
281289
281415
|
const role = input.role && input.role.length > 0 ? input.role : ABLO_REPLICATION_ROLE;
|
|
281416
|
+
const writeRole = input.writeRole && input.writeRole.length > 0 ? input.writeRole : ABLO_WRITE_ROLE;
|
|
281290
281417
|
const tables = input.tables ?? [];
|
|
281291
281418
|
const publicationTarget = tables.length > 0 ? `FOR TABLE ${tables.map(quoteIdent).join(", ")}` : "FOR ALL TABLES";
|
|
281419
|
+
const applicationGrant = tables.length > 0 ? `GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE ${tables.map(quoteIdent).join(", ")} TO ${quoteIdent(writeRole)};` : `GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO ${quoteIdent(writeRole)};`;
|
|
281420
|
+
const ledger = idempotencyLedgerMigrations().map((migration) => migration.up);
|
|
281292
281421
|
return [
|
|
281293
281422
|
// 1. Turn on logical decoding. Requires a restart (it's not reloadable).
|
|
281294
281423
|
`ALTER SYSTEM SET wal_level = 'logical';`,
|
|
@@ -281298,18 +281427,58 @@ function connectSetupSql(input) {
|
|
|
281298
281427
|
`CREATE ROLE ${quoteIdent(role)} WITH REPLICATION LOGIN PASSWORD '<password>';`,
|
|
281299
281428
|
`GRANT SELECT ON ALL TABLES IN SCHEMA public TO ${quoteIdent(role)};`,
|
|
281300
281429
|
// Future tables get SELECT automatically, so the publication doesn't outgrow the grant.
|
|
281301
|
-
`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO ${quoteIdent(role)}
|
|
281430
|
+
`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO ${quoteIdent(role)};`,
|
|
281431
|
+
// 4. A distinct DML role: no replication, role administration, ownership,
|
|
281432
|
+
// schema creation, or DDL. It is subject to RLS on every transaction.
|
|
281433
|
+
`CREATE ROLE ${quoteIdent(writeRole)} WITH LOGIN PASSWORD '<write-password>' NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE NOREPLICATION NOINHERIT;`,
|
|
281434
|
+
`ALTER ROLE ${quoteIdent(writeRole)} SET row_security = on;`,
|
|
281435
|
+
// PUBLIC can carry CREATE on older Postgres clusters. A role-level REVOKE
|
|
281436
|
+
// cannot override that inherited grant, so harden the schema explicitly.
|
|
281437
|
+
`REVOKE CREATE ON SCHEMA public FROM PUBLIC;`,
|
|
281438
|
+
`GRANT USAGE ON SCHEMA public TO ${quoteIdent(writeRole)};`,
|
|
281439
|
+
applicationGrant,
|
|
281440
|
+
`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ${quoteIdent(writeRole)};`,
|
|
281441
|
+
...tables.length === 0 ? [
|
|
281442
|
+
`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${quoteIdent(writeRole)};`,
|
|
281443
|
+
`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO ${quoteIdent(writeRole)};`
|
|
281444
|
+
] : [],
|
|
281445
|
+
// 5. Direct uses the durable replay ledger but deliberately no outbox.
|
|
281446
|
+
...ledger,
|
|
281447
|
+
`REVOKE ALL ON TABLE public.ablo_idempotency FROM PUBLIC;`,
|
|
281448
|
+
`GRANT SELECT, INSERT, UPDATE ON TABLE public.ablo_idempotency TO ${quoteIdent(writeRole)};`,
|
|
281449
|
+
`REVOKE DELETE ON TABLE public.ablo_idempotency FROM ${quoteIdent(writeRole)};`,
|
|
281450
|
+
// Grant every pg_logical_emit_message variant by lookup instead of one
|
|
281451
|
+
// literal signature: PostgreSQL 17 adds an optional fourth `flush`
|
|
281452
|
+
// parameter, so the historical three-argument form no longer exists there
|
|
281453
|
+
// and a signature-pinned GRANT fails on an otherwise healthy database.
|
|
281454
|
+
`DO $$
|
|
281455
|
+
DECLARE fn regprocedure;
|
|
281456
|
+
BEGIN
|
|
281457
|
+
FOR fn IN
|
|
281458
|
+
SELECT p.oid::regprocedure
|
|
281459
|
+
FROM pg_proc p
|
|
281460
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
281461
|
+
WHERE n.nspname = 'pg_catalog' AND p.proname = 'pg_logical_emit_message'
|
|
281462
|
+
LOOP
|
|
281463
|
+
EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO ${quoteIdent(writeRole)}', fn);
|
|
281464
|
+
END LOOP;
|
|
281465
|
+
END $$;`
|
|
281302
281466
|
];
|
|
281303
281467
|
}
|
|
281304
281468
|
function printConnectRecipe(args) {
|
|
281305
|
-
const sql = connectSetupSql({
|
|
281469
|
+
const sql = connectSetupSql({
|
|
281470
|
+
tables: args.tables,
|
|
281471
|
+
role: args.role,
|
|
281472
|
+
writeRole: args.writeRole
|
|
281473
|
+
});
|
|
281306
281474
|
console.log(`
|
|
281307
|
-
${brand("ablo")} ${import_picocolors6.default.dim("connect")} ${import_picocolors6.default.dim("
|
|
281475
|
+
${brand("ablo")} ${import_picocolors6.default.dim("connect")} ${import_picocolors6.default.dim("direct writes + WAL-settled sync")}
|
|
281308
281476
|
`);
|
|
281309
281477
|
console.log(
|
|
281310
|
-
` Ablo
|
|
281311
|
-
|
|
281312
|
-
|
|
281478
|
+
` Ablo applies coordinated writes directly to your Postgres with a scoped DML role. WAL
|
|
281479
|
+
observes what committed, orders it with external writes, and confirms it in the sync log.
|
|
281480
|
+
Your database stays authoritative; Ablo never owns or migrates your application tables.
|
|
281481
|
+
Run this once against your Postgres ${import_picocolors6.default.dim("(as a superuser / DB owner)")}:
|
|
281313
281482
|
`
|
|
281314
281483
|
);
|
|
281315
281484
|
console.log(` ${import_picocolors6.default.bold("1.")} Enable logical decoding ${import_picocolors6.default.dim("(then RESTART Postgres \u2014 wal_level is not reloadable)")}`);
|
|
@@ -281327,7 +281496,7 @@ function printConnectRecipe(args) {
|
|
|
281327
281496
|
console.log(import_picocolors6.default.dim(` (Scope it with ${import_picocolors6.default.bold("ablo connect --tables a,b,c")} to publish a subset.)`));
|
|
281328
281497
|
}
|
|
281329
281498
|
console.log(`
|
|
281330
|
-
${import_picocolors6.default.bold("3.")} Create
|
|
281499
|
+
${import_picocolors6.default.bold("3.")} Create the scoped replication role ${import_picocolors6.default.dim("(pick your own replication password)")}`);
|
|
281331
281500
|
console.log(` ${import_picocolors6.default.cyan(sql[2])}`);
|
|
281332
281501
|
console.log(` ${import_picocolors6.default.cyan(sql[3])}`);
|
|
281333
281502
|
console.log(` ${import_picocolors6.default.cyan(sql[4])}`);
|
|
@@ -281339,15 +281508,31 @@ function printConnectRecipe(args) {
|
|
|
281339
281508
|
);
|
|
281340
281509
|
console.log(
|
|
281341
281510
|
`
|
|
281342
|
-
${import_picocolors6.default.bold("4.")}
|
|
281511
|
+
${import_picocolors6.default.bold("4.")} Create the separate DML role and permanent idempotency ledger ` + import_picocolors6.default.dim("(pick your own write password)")
|
|
281512
|
+
);
|
|
281513
|
+
for (const statement of sql.slice(5)) {
|
|
281514
|
+
for (const line of statement.split("\n")) console.log(` ${import_picocolors6.default.cyan(line)}`);
|
|
281515
|
+
}
|
|
281516
|
+
console.log(
|
|
281517
|
+
import_picocolors6.default.dim(
|
|
281518
|
+
` The writer gets row DML + ledger access only. It has no REPLICATION, schema CREATE,
|
|
281519
|
+
role administration, database creation, ownership, or customer-table DDL. Direct uses
|
|
281520
|
+
${import_picocolors6.default.bold("ablo_idempotency")} but no outbox; WAL carries the committed row changes.`
|
|
281521
|
+
)
|
|
281522
|
+
);
|
|
281523
|
+
console.log(
|
|
281524
|
+
`
|
|
281525
|
+
${import_picocolors6.default.bold("5.")} Configure both scoped connections, then verify:
|
|
281526
|
+
${import_picocolors6.default.bold("DATABASE_URL")} ${import_picocolors6.default.dim(`\u2192 ${args.role} (replication only)`)}
|
|
281527
|
+
${import_picocolors6.default.bold("ABLO_WRITE_DATABASE_URL")} ${import_picocolors6.default.dim(`\u2192 ${args.writeRole} (DML only)`)}
|
|
281343
281528
|
${import_picocolors6.default.cyan("npx ablo connect --check")}
|
|
281344
281529
|
`
|
|
281345
281530
|
);
|
|
281346
281531
|
console.log(
|
|
281347
281532
|
import_picocolors6.default.dim(
|
|
281348
|
-
`
|
|
281349
|
-
|
|
281350
|
-
|
|
281533
|
+
` Reachable databases use this direct path. If no inbound route can be established, use the
|
|
281534
|
+
signed ${import_picocolors6.default.bold("dataSource()")} endpoint fallback; its correlated events confirm writes
|
|
281535
|
+
without an Ablo-side customer database socket.`
|
|
281351
281536
|
)
|
|
281352
281537
|
);
|
|
281353
281538
|
console.log();
|
|
@@ -281448,6 +281633,76 @@ On RDS: GRANT rds_replication TO <your_role>;`
|
|
|
281448
281633
|
}
|
|
281449
281634
|
return items;
|
|
281450
281635
|
}
|
|
281636
|
+
async function probeDirectWriteReadiness(sql, opts = {}) {
|
|
281637
|
+
const schema = opts.schema ?? "public";
|
|
281638
|
+
const publication = opts.publication ?? ABLO_PUBLICATION;
|
|
281639
|
+
const items = [];
|
|
281640
|
+
const roleRows = await sql.unsafe(
|
|
281641
|
+
`SELECT rolname, rolsuper, rolbypassrls, rolcreatedb, rolcreaterole, rolreplication
|
|
281642
|
+
FROM pg_roles WHERE rolname = current_user`
|
|
281643
|
+
);
|
|
281644
|
+
const role = roleRows[0];
|
|
281645
|
+
const dangerous = Boolean(
|
|
281646
|
+
!role || role.rolsuper || role.rolbypassrls || role.rolcreatedb || role.rolcreaterole || role.rolreplication
|
|
281647
|
+
);
|
|
281648
|
+
items.push(
|
|
281649
|
+
dangerous ? {
|
|
281650
|
+
ok: false,
|
|
281651
|
+
label: `ABLO_WRITE_DATABASE_URL is not a scoped DML role`,
|
|
281652
|
+
fix: `Use ${ABLO_WRITE_ROLE} from the setup recipe: NOSUPERUSER, NOBYPASSRLS, NOCREATEDB, NOCREATEROLE, NOREPLICATION.`
|
|
281653
|
+
} : { ok: true, label: `write role ${import_picocolors6.default.bold(role?.rolname ?? ABLO_WRITE_ROLE)} is DML-only` }
|
|
281654
|
+
);
|
|
281655
|
+
const schemaRows = await sql.unsafe(
|
|
281656
|
+
`SELECT
|
|
281657
|
+
has_schema_privilege(current_user, $1, 'USAGE') AS usage,
|
|
281658
|
+
has_schema_privilege(current_user, $1, 'CREATE') AS create,
|
|
281659
|
+
current_setting('row_security') AS row_security`,
|
|
281660
|
+
[schema]
|
|
281661
|
+
);
|
|
281662
|
+
const schemaRow = schemaRows[0];
|
|
281663
|
+
items.push(
|
|
281664
|
+
schemaRow?.usage && !schemaRow.create && schemaRow.row_security === "on" ? { ok: true, label: `writer uses ${schema} with RLS on and no schema CREATE` } : {
|
|
281665
|
+
ok: false,
|
|
281666
|
+
label: `writer schema/RLS privileges are not least-privilege`,
|
|
281667
|
+
fix: `REVOKE CREATE ON SCHEMA ${quoteIdent(schema)} FROM PUBLIC; GRANT USAGE ON SCHEMA ${quoteIdent(schema)} TO ${quoteIdent(role?.rolname ?? ABLO_WRITE_ROLE)}; ALTER ROLE ${quoteIdent(role?.rolname ?? ABLO_WRITE_ROLE)} SET row_security = on;`
|
|
281668
|
+
}
|
|
281669
|
+
);
|
|
281670
|
+
const ledgerName = `${quoteIdent(schema)}.${quoteIdent("ablo_idempotency")}`;
|
|
281671
|
+
const ledgerRows = await sql.unsafe(
|
|
281672
|
+
`SELECT
|
|
281673
|
+
to_regclass($1) IS NOT NULL AS present,
|
|
281674
|
+
CASE WHEN to_regclass($1) IS NULL THEN false ELSE has_table_privilege(current_user, $1, 'SELECT,INSERT,UPDATE') END AS writes,
|
|
281675
|
+
CASE WHEN to_regclass($1) IS NULL THEN false ELSE has_table_privilege(current_user, $1, 'DELETE') END AS deletes`,
|
|
281676
|
+
[ledgerName]
|
|
281677
|
+
);
|
|
281678
|
+
const ledger = ledgerRows[0];
|
|
281679
|
+
items.push(
|
|
281680
|
+
ledger?.present && ledger.writes && !ledger.deletes ? { ok: true, label: `${import_picocolors6.default.bold("ablo_idempotency")} is durable and protected from DELETE` } : {
|
|
281681
|
+
ok: false,
|
|
281682
|
+
label: `${import_picocolors6.default.bold("ablo_idempotency")} is missing or has unsafe grants`,
|
|
281683
|
+
fix: `Apply the DML-role and idempotency-ledger statements printed by ${import_picocolors6.default.bold("ablo connect")}.`
|
|
281684
|
+
}
|
|
281685
|
+
);
|
|
281686
|
+
const tableRows = await sql.unsafe(
|
|
281687
|
+
`SELECT format('%I.%I', schemaname, tablename) AS relation
|
|
281688
|
+
FROM pg_publication_tables
|
|
281689
|
+
WHERE pubname = $1 AND schemaname = $2 AND tablename <> 'ablo_idempotency'
|
|
281690
|
+
AND NOT has_table_privilege(
|
|
281691
|
+
current_user,
|
|
281692
|
+
format('%I.%I', schemaname, tablename),
|
|
281693
|
+
'SELECT,INSERT,UPDATE,DELETE'
|
|
281694
|
+
)`,
|
|
281695
|
+
[publication, schema]
|
|
281696
|
+
);
|
|
281697
|
+
items.push(
|
|
281698
|
+
tableRows.length === 0 ? { ok: true, label: `writer can apply DML to every published application table` } : {
|
|
281699
|
+
ok: false,
|
|
281700
|
+
label: `writer lacks DML on ${tableRows.length} published table${tableRows.length === 1 ? "" : "s"}`,
|
|
281701
|
+
fix: `Grant SELECT, INSERT, UPDATE, DELETE on: ${tableRows.map((row) => row.relation).join(", ")}`
|
|
281702
|
+
}
|
|
281703
|
+
);
|
|
281704
|
+
return items;
|
|
281705
|
+
}
|
|
281451
281706
|
async function auditTenantSyncInfra(sql) {
|
|
281452
281707
|
const artifacts = [];
|
|
281453
281708
|
for (const name of SYNC_INFRA_RELATIONS) {
|
|
@@ -281476,12 +281731,24 @@ function requireDatabaseUrl(verb) {
|
|
|
281476
281731
|
}
|
|
281477
281732
|
return dbUrl;
|
|
281478
281733
|
}
|
|
281479
|
-
|
|
281734
|
+
function requireWriteDatabaseUrl(verb) {
|
|
281735
|
+
const dbUrl = readProjectWriteDatabaseUrl();
|
|
281736
|
+
if (!dbUrl) {
|
|
281737
|
+
console.error(
|
|
281738
|
+
import_picocolors6.default.red(" No ABLO_WRITE_DATABASE_URL found (checked process env, .env.local, .env).") + import_picocolors6.default.dim(
|
|
281739
|
+
` Set it to the ${ABLO_WRITE_ROLE} connection printed by ${import_picocolors6.default.bold("ablo connect")}, then re-run ${import_picocolors6.default.bold(`ablo connect ${verb}`)}. The replication credential is never reused for DML.`
|
|
281740
|
+
)
|
|
281741
|
+
);
|
|
281742
|
+
process.exit(1);
|
|
281743
|
+
}
|
|
281744
|
+
return dbUrl;
|
|
281745
|
+
}
|
|
281746
|
+
async function probeAndReport(dbUrl, kind = "replication") {
|
|
281480
281747
|
const sql = src_default(dbUrl, { max: 1, prepare: false, connect_timeout: 10, onnotice: () => {
|
|
281481
281748
|
} });
|
|
281482
281749
|
let items;
|
|
281483
281750
|
try {
|
|
281484
|
-
items = await probeReadiness(sql);
|
|
281751
|
+
items = kind === "replication" ? await probeReadiness(sql) : await probeDirectWriteReadiness(sql);
|
|
281485
281752
|
} catch (err) {
|
|
281486
281753
|
await sql.end({ timeout: 2 }).catch(() => void 0);
|
|
281487
281754
|
const dial = dialFailureReason(err);
|
|
@@ -281494,11 +281761,11 @@ async function probeAndReport(dbUrl) {
|
|
|
281494
281761
|
for (const item of items) printCheckItem(item);
|
|
281495
281762
|
return { kind: "probed", failures: items.filter((i) => !i.ok).length };
|
|
281496
281763
|
}
|
|
281497
|
-
async function runRemoteCheck(dbUrl, localReason) {
|
|
281764
|
+
async function runRemoteCheck(dbUrl, writeDbUrl, localReason) {
|
|
281498
281765
|
console.log(
|
|
281499
|
-
` This machine can't reach
|
|
281500
|
-
That is not the verdict: replication
|
|
281501
|
-
Asking Ablo to check
|
|
281766
|
+
` This machine can't reach one or both scoped connections (${import_picocolors6.default.dim(localReason)}).
|
|
281767
|
+
That is not the verdict: direct writes and replication run from Ablo's infrastructure.
|
|
281768
|
+
Asking Ablo to check both roles from its side\u2026
|
|
281502
281769
|
`
|
|
281503
281770
|
);
|
|
281504
281771
|
const apiKey = resolveApiKey();
|
|
@@ -281511,7 +281778,12 @@ async function runRemoteCheck(dbUrl, localReason) {
|
|
|
281511
281778
|
process.exit(1);
|
|
281512
281779
|
}
|
|
281513
281780
|
const apiUrl2 = (process.env.ABLO_API_URL ?? DEFAULT_URL).replace(/\/+$/, "");
|
|
281514
|
-
const result = await requestRemoteValidation({
|
|
281781
|
+
const result = await requestRemoteValidation({
|
|
281782
|
+
apiUrl: apiUrl2,
|
|
281783
|
+
apiKey,
|
|
281784
|
+
connectionString: dbUrl,
|
|
281785
|
+
writeConnectionString: writeDbUrl
|
|
281786
|
+
});
|
|
281515
281787
|
if (!result.ok) {
|
|
281516
281788
|
console.error(import_picocolors6.default.red(` The engine-side check failed: ${result.message}`));
|
|
281517
281789
|
if (result.code === "forbidden") {
|
|
@@ -281524,13 +281796,12 @@ async function runRemoteCheck(dbUrl, localReason) {
|
|
|
281524
281796
|
}
|
|
281525
281797
|
if (!result.reachable) {
|
|
281526
281798
|
console.error(
|
|
281527
|
-
` ${import_picocolors6.default.red("\u2717")} Ablo's infrastructure can't reach
|
|
281799
|
+
` ${import_picocolors6.default.red("\u2717")} Ablo's infrastructure can't reach both direct connections${result.reason ? ` ${import_picocolors6.default.dim(`(${result.reason})`)}` : ""}.`
|
|
281528
281800
|
);
|
|
281529
281801
|
console.error(
|
|
281530
281802
|
import_picocolors6.default.dim(
|
|
281531
|
-
`
|
|
281532
|
-
|
|
281533
|
-
or the signed ${import_picocolors6.default.bold("dataSource()")} endpoint fallback.
|
|
281803
|
+
` Direct needs a route Ablo's servers can dial \u2014 public allowlist, PrivateLink, peering,
|
|
281804
|
+
or VPN. Only when no inbound route can exist, use the signed ${import_picocolors6.default.bold("dataSource()")} endpoint fallback.
|
|
281534
281805
|
`
|
|
281535
281806
|
)
|
|
281536
281807
|
);
|
|
@@ -281543,7 +281814,7 @@ async function runRemoteCheck(dbUrl, localReason) {
|
|
|
281543
281814
|
console.log();
|
|
281544
281815
|
if (result.ready) {
|
|
281545
281816
|
console.log(
|
|
281546
|
-
` ${import_picocolors6.default.green("\u2713")} Ready \u2014 checked from Ablo's infrastructure.
|
|
281817
|
+
` ${import_picocolors6.default.green("\u2713")} Ready \u2014 checked from Ablo's infrastructure. Both direct DML and WAL settlement are available.
|
|
281547
281818
|
`
|
|
281548
281819
|
);
|
|
281549
281820
|
process.exit(0);
|
|
@@ -281557,17 +281828,28 @@ async function runRemoteCheck(dbUrl, localReason) {
|
|
|
281557
281828
|
}
|
|
281558
281829
|
async function runCheck() {
|
|
281559
281830
|
const dbUrl = requireDatabaseUrl("--check");
|
|
281831
|
+
const writeDbUrl = requireWriteDatabaseUrl("--check");
|
|
281832
|
+
console.log(`
|
|
281833
|
+
${brand("ablo")} ${import_picocolors6.default.dim("connect --check")} ${import_picocolors6.default.dim("direct-write + WAL readiness")}
|
|
281834
|
+
`);
|
|
281835
|
+
console.log(` ${import_picocolors6.default.bold("Replication role")}
|
|
281836
|
+
`);
|
|
281837
|
+
const replication = await probeAndReport(dbUrl, "replication");
|
|
281560
281838
|
console.log(`
|
|
281561
|
-
${
|
|
281839
|
+
${import_picocolors6.default.bold("Direct-write role")}
|
|
281562
281840
|
`);
|
|
281563
|
-
const
|
|
281564
|
-
if (
|
|
281565
|
-
|
|
281841
|
+
const write = await probeAndReport(writeDbUrl, "write");
|
|
281842
|
+
if (replication.kind === "no-dial" || write.kind === "no-dial") {
|
|
281843
|
+
const reasons = [
|
|
281844
|
+
replication.kind === "no-dial" ? `replication: ${replication.reason}` : null,
|
|
281845
|
+
write.kind === "no-dial" ? `write: ${write.reason}` : null
|
|
281846
|
+
].filter((reason) => reason !== null);
|
|
281847
|
+
return runRemoteCheck(dbUrl, writeDbUrl, reasons.join("; "));
|
|
281566
281848
|
}
|
|
281567
|
-
const failures =
|
|
281849
|
+
const failures = replication.failures + write.failures;
|
|
281568
281850
|
console.log();
|
|
281569
281851
|
if (failures === 0) {
|
|
281570
|
-
console.log(` ${import_picocolors6.default.green("\u2713")} Ready \u2014 Ablo can
|
|
281852
|
+
console.log(` ${import_picocolors6.default.green("\u2713")} Ready \u2014 Ablo can apply scoped DML and settle it from WAL.
|
|
281571
281853
|
`);
|
|
281572
281854
|
process.exit(0);
|
|
281573
281855
|
}
|
|
@@ -281580,8 +281862,9 @@ async function runCheck() {
|
|
|
281580
281862
|
function registerEndpoint(baseUrl2) {
|
|
281581
281863
|
return `${baseUrl2.replace(/\/+$/, "")}/api/v1/datasources`;
|
|
281582
281864
|
}
|
|
281583
|
-
async function runRegister() {
|
|
281865
|
+
async function runRegister(args) {
|
|
281584
281866
|
const dbUrl = requireDatabaseUrl("--register");
|
|
281867
|
+
const writeDbUrl = requireWriteDatabaseUrl("--register");
|
|
281585
281868
|
const apiKey = resolveApiKey();
|
|
281586
281869
|
if (!apiKey) {
|
|
281587
281870
|
console.error(
|
|
@@ -281590,20 +281873,32 @@ async function runRegister() {
|
|
|
281590
281873
|
process.exit(1);
|
|
281591
281874
|
}
|
|
281592
281875
|
console.log(`
|
|
281593
|
-
${brand("ablo")} ${import_picocolors6.default.dim("connect --register")} ${import_picocolors6.default.dim("register
|
|
281876
|
+
${brand("ablo")} ${import_picocolors6.default.dim("connect --register")} ${import_picocolors6.default.dim("register a direct DataSource")}
|
|
281877
|
+
`);
|
|
281878
|
+
console.log(` ${import_picocolors6.default.bold("Replication role")}
|
|
281879
|
+
`);
|
|
281880
|
+
const replication = await probeAndReport(dbUrl, "replication");
|
|
281881
|
+
console.log(`
|
|
281882
|
+
${import_picocolors6.default.bold("Direct-write role")}
|
|
281594
281883
|
`);
|
|
281595
|
-
const
|
|
281596
|
-
|
|
281884
|
+
const write = await probeAndReport(writeDbUrl, "write");
|
|
281885
|
+
const noDial = [
|
|
281886
|
+
replication.kind === "no-dial" ? `replication: ${replication.reason}` : null,
|
|
281887
|
+
write.kind === "no-dial" ? `write: ${write.reason}` : null
|
|
281888
|
+
].filter((reason) => reason !== null);
|
|
281889
|
+
const failures = (replication.kind === "probed" ? replication.failures : 0) + (write.kind === "probed" ? write.failures : 0);
|
|
281890
|
+
if (noDial.length > 0) {
|
|
281597
281891
|
console.log(
|
|
281598
|
-
` This machine can't reach
|
|
281599
|
-
|
|
281600
|
-
registration
|
|
281892
|
+
` This machine can't reach one or both scoped connections (${import_picocolors6.default.dim(noDial.join("; "))}) \u2014 continuing anyway.
|
|
281893
|
+
Ablo validates both credentials from the infrastructure that will use them and refuses
|
|
281894
|
+
registration unless replication and direct DML are both ready.
|
|
281601
281895
|
`
|
|
281602
281896
|
);
|
|
281603
|
-
}
|
|
281897
|
+
}
|
|
281898
|
+
if (failures > 0) {
|
|
281604
281899
|
console.log(
|
|
281605
281900
|
`
|
|
281606
|
-
${import_picocolors6.default.red(`${
|
|
281901
|
+
${import_picocolors6.default.red(`${failures} item${failures === 1 ? "" : "s"} to fix`)} ${import_picocolors6.default.dim("\u2014 direct registration requires both scoped roles. Fix the above, then re-run.")}
|
|
281607
281902
|
`
|
|
281608
281903
|
);
|
|
281609
281904
|
process.exit(1);
|
|
@@ -281614,7 +281909,12 @@ async function runRegister() {
|
|
|
281614
281909
|
res = await fetch(registerEndpoint(apiUrl2), {
|
|
281615
281910
|
method: "POST",
|
|
281616
281911
|
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
|
|
281617
|
-
body: JSON.stringify({
|
|
281912
|
+
body: JSON.stringify({
|
|
281913
|
+
connection: "direct",
|
|
281914
|
+
connectionString: dbUrl,
|
|
281915
|
+
writeConnectionString: writeDbUrl,
|
|
281916
|
+
route: args.route
|
|
281917
|
+
})
|
|
281618
281918
|
});
|
|
281619
281919
|
} catch (err) {
|
|
281620
281920
|
console.error(import_picocolors6.default.red(`
|
|
@@ -281624,9 +281924,11 @@ async function runRegister() {
|
|
|
281624
281924
|
}
|
|
281625
281925
|
if (res.ok) {
|
|
281626
281926
|
const body2 = await res.json().catch(() => ({}));
|
|
281927
|
+
const statusNote = body2.status === "active" ? `${args.route}, active` : args.route;
|
|
281627
281928
|
console.log(
|
|
281628
281929
|
`
|
|
281629
|
-
${import_picocolors6.default.green("\u2713")} Registered${body2.host ? ` ${import_picocolors6.default.dim(body2.host)}` : ""}${body2.id ? ` ${import_picocolors6.default.dim(`(${body2.id})`)}` : ""}
|
|
281930
|
+
${import_picocolors6.default.green("\u2713")} Registered${body2.host ? ` ${import_picocolors6.default.dim(body2.host)}` : ""}${body2.id ? ` ${import_picocolors6.default.dim(`(${body2.id})`)}` : ""} as a direct DataSource (${statusNote}).
|
|
281931
|
+
Customer COMMIT is durable acceptance; correlated WAL promotes queued writes to confirmed.
|
|
281630
281932
|
`
|
|
281631
281933
|
);
|
|
281632
281934
|
process.exit(0);
|
|
@@ -281642,20 +281944,20 @@ async function runRegister() {
|
|
|
281642
281944
|
console.error(
|
|
281643
281945
|
import_picocolors6.default.dim(` This deployment can\u2019t accept connection strings \u2014 use a self-hosted/hosted engine, or the signed endpoint fallback.`)
|
|
281644
281946
|
);
|
|
281645
|
-
} else if (code === "database_not_replication_ready") {
|
|
281947
|
+
} else if (code === "database_not_replication_ready" || code === "data_source_blocked") {
|
|
281646
281948
|
for (const f of body.details?.failures ?? []) {
|
|
281647
281949
|
console.error(` ${import_picocolors6.default.red("\u2717")} ${import_picocolors6.default.bold(f.item ?? "item")}${f.actual ? import_picocolors6.default.dim(` (${f.actual})`) : ""}`);
|
|
281648
281950
|
if (f.fix) for (const line of f.fix.split("\n")) console.error(` ${import_picocolors6.default.red("\u2022")} ${line}`);
|
|
281649
281951
|
}
|
|
281650
281952
|
console.error(import_picocolors6.default.dim(`
|
|
281651
281953
|
Apply the fixes, verify with ${import_picocolors6.default.bold("ablo connect --check")}, then re-run.`));
|
|
281652
|
-
} else if (code === "database_unreachable") {
|
|
281954
|
+
} else if (code === "database_unreachable" || code === "source_unreachable") {
|
|
281653
281955
|
if (body.details?.reason) console.error(import_picocolors6.default.dim(` ${body.details.reason}`));
|
|
281654
281956
|
console.error(
|
|
281655
281957
|
import_picocolors6.default.dim(
|
|
281656
281958
|
` Ablo's servers must be able to reach this database \u2014 a localhost or private-network
|
|
281657
|
-
Postgres can't
|
|
281658
|
-
|
|
281959
|
+
Postgres can't use the direct path. Establish an allowlist, PrivateLink, peering, or VPN.
|
|
281960
|
+
Only when no inbound route is possible, register the signed ${import_picocolors6.default.bold("dataSource()")} endpoint fallback.`
|
|
281659
281961
|
)
|
|
281660
281962
|
);
|
|
281661
281963
|
}
|
|
@@ -281708,7 +282010,7 @@ async function connect(argv) {
|
|
|
281708
282010
|
return;
|
|
281709
282011
|
}
|
|
281710
282012
|
if (args.register) {
|
|
281711
|
-
await runRegister();
|
|
282013
|
+
await runRegister(args);
|
|
281712
282014
|
return;
|
|
281713
282015
|
}
|
|
281714
282016
|
if (args.auditInfra) {
|
|
@@ -281717,18 +282019,19 @@ async function connect(argv) {
|
|
|
281717
282019
|
}
|
|
281718
282020
|
printConnectRecipe(args);
|
|
281719
282021
|
}
|
|
281720
|
-
var CONNECT_USAGE = ` ablo connect \u2014
|
|
282022
|
+
var CONNECT_USAGE = ` ablo connect \u2014 direct writes, settled by logical replication
|
|
281721
282023
|
|
|
281722
|
-
|
|
281723
|
-
|
|
282024
|
+
Ablo applies coordinated DML with a scoped writer role. WAL observes what committed,
|
|
282025
|
+
orders it with external changes, and confirms it. Your Postgres remains authoritative.
|
|
281724
282026
|
|
|
281725
282027
|
Usage:
|
|
281726
282028
|
npx ablo connect Print the exact setup SQL for your Postgres
|
|
281727
282029
|
npx ablo connect --tables a,b,c Publish only these tables (default: all tables)
|
|
281728
282030
|
npx ablo connect --role <name> Name the replication role (default: ablo_replicator)
|
|
281729
|
-
npx ablo connect --
|
|
281730
|
-
|
|
281731
|
-
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
|
|
281732
282035
|
npx ablo connect --audit-infra Read-only Stage 5 audit for deprecated Ablo sync tables/types`;
|
|
281733
282036
|
|
|
281734
282037
|
// src/cli/generate.ts
|
|
@@ -283993,7 +284296,7 @@ async function main() {
|
|
|
283993
284296
|
console.log(` ${import_picocolors20.default.bold("Usage:")}`);
|
|
283994
284297
|
console.log(` npx ablo init Scaffold ablo/ directory + starter schema`);
|
|
283995
284298
|
console.log(` npx ablo init --yes [--framework nextjs] Non-interactive (agents/CI): no prompts, flag-driven`);
|
|
283996
|
-
console.log(` [--auth apikey] [--storage
|
|
284299
|
+
console.log(` [--auth apikey] [--storage replication|endpoint] [--project <slug>] [--no-project]`);
|
|
283997
284300
|
console.log(` [--no-agent] [--no-pull] [--no-install] [--no-login]`);
|
|
283998
284301
|
console.log(` npx ablo login Authorize in your browser (provisions sandbox + production keys)`);
|
|
283999
284302
|
console.log(` npx ablo login --project <slug> Same, scoped to a project (mints its keys, makes it active)`);
|
|
@@ -284041,7 +284344,7 @@ function bailIfCancelled(value) {
|
|
|
284041
284344
|
}
|
|
284042
284345
|
var INIT_FRAMEWORKS = ["nextjs", "vite", "remix", "vanilla"];
|
|
284043
284346
|
var INIT_AUTHS = ["apikey", "firebase", "auth0", "clerk", "supabase", "betterauth", "jwt"];
|
|
284044
|
-
var INIT_STORAGES = ["replication", "endpoint", "
|
|
284347
|
+
var INIT_STORAGES = ["replication", "endpoint", "datasource"];
|
|
284045
284348
|
function parseInitArgs(args) {
|
|
284046
284349
|
const has = (flag2) => args.includes(flag2);
|
|
284047
284350
|
const val = (flag2) => {
|
|
@@ -284173,12 +284476,6 @@ async function init(args = []) {
|
|
|
284173
284476
|
() => Promise.resolve("replication")
|
|
284174
284477
|
);
|
|
284175
284478
|
const storage = storageChoice === "datasource" ? "endpoint" : storageChoice;
|
|
284176
|
-
if (storage === "direct") {
|
|
284177
|
-
Me(
|
|
284178
|
-
"`--storage direct` uses the deprecated databaseUrl connector. Logical replication\n(`npx ablo connect`) is the supported path; a signed Data Source endpoint is the fallback.",
|
|
284179
|
-
import_picocolors20.default.yellow("Deprecated")
|
|
284180
|
-
);
|
|
284181
|
-
}
|
|
284182
284479
|
const agent = await chooseBool(
|
|
284183
284480
|
opts.agent,
|
|
284184
284481
|
true,
|
|
@@ -284227,7 +284524,7 @@ async function init(args = []) {
|
|
|
284227
284524
|
}
|
|
284228
284525
|
(0, import_fs12.writeFileSync)((0, import_path7.join)(abloDir, "schema.ts"), schemaSource);
|
|
284229
284526
|
created.push(`${abloDir}/schema.ts${schemaNote}`);
|
|
284230
|
-
(0, import_fs12.writeFileSync)((0, import_path7.join)(abloDir, "index.ts"), generateSyncConfig(auth
|
|
284527
|
+
(0, import_fs12.writeFileSync)((0, import_path7.join)(abloDir, "index.ts"), generateSyncConfig(auth));
|
|
284231
284528
|
created.push(`${abloDir}/index.ts`);
|
|
284232
284529
|
(0, import_fs12.writeFileSync)((0, import_path7.join)(abloDir, "register.ts"), generateRegister());
|
|
284233
284530
|
created.push(`${abloDir}/register.ts`);
|
|
@@ -284300,8 +284597,6 @@ async function init(args = []) {
|
|
|
284300
284597
|
`Connect your database \u2014 ${import_picocolors20.default.bold("npx ablo connect")} prints the one-time logical-replication setup SQL to run on your Postgres`,
|
|
284301
284598
|
`Verify it \u2014 ${import_picocolors20.default.bold("npx ablo connect --check")} walks wal_level, the publication, the role, and replica identity, with the exact fix for anything missing`,
|
|
284302
284599
|
`Register it \u2014 ${import_picocolors20.default.bold("npx ablo connect --register")} tells Ablo to start replicating; your app keeps writing through your own backend while Ablo tails the WAL`
|
|
284303
|
-
] : storage === "direct" ? [
|
|
284304
|
-
`Provision your DB: ${import_picocolors20.default.bold("npx ablo migrate")} (creates your synced-model tables with row-level security; keep your own migrations for everything else)`
|
|
284305
284600
|
] : [
|
|
284306
284601
|
`Provision your DB: ${import_picocolors20.default.bold("npx ablo migrate")} (creates your Ablo-model tables + the adapter tables; keep your own migrations for everything else), then mount ${import_picocolors20.default.bold(`${abloDir}/data-source.ts`)} at ${import_picocolors20.default.bold("/api/ablo/source")}`
|
|
284307
284602
|
],
|
|
@@ -284359,9 +284654,7 @@ export const schema = defineSchema({
|
|
|
284359
284654
|
});
|
|
284360
284655
|
`;
|
|
284361
284656
|
}
|
|
284362
|
-
function generateSyncConfig(auth
|
|
284363
|
-
const databaseLine = storage === "direct" ? `
|
|
284364
|
-
databaseUrl: process.env.DATABASE_URL, // deprecated direct connector` : "";
|
|
284657
|
+
function generateSyncConfig(auth) {
|
|
284365
284658
|
const authLine = auth === "apikey" ? "" : auth === "firebase" ? `
|
|
284366
284659
|
auth: async () => {
|
|
284367
284660
|
const { getAuth } = await import('firebase/auth');
|
|
@@ -284381,7 +284674,7 @@ import { schema } from './schema';
|
|
|
284381
284674
|
// ('use client') component; the browser uses app/providers.tsx, which authenticates
|
|
284382
284675
|
// via the session route and never touches the key.
|
|
284383
284676
|
export const sync = Ablo({
|
|
284384
|
-
apiKey: process.env.ABLO_API_KEY,${
|
|
284677
|
+
apiKey: process.env.ABLO_API_KEY,${authLine}
|
|
284385
284678
|
schema,
|
|
284386
284679
|
});
|
|
284387
284680
|
|
|
@@ -284405,7 +284698,7 @@ export {};
|
|
|
284405
284698
|
}
|
|
284406
284699
|
function generateEnv(storage, opts = {}) {
|
|
284407
284700
|
const { includeApiKey = true } = opts;
|
|
284408
|
-
const databaseBlock = storage === "
|
|
284701
|
+
const databaseBlock = storage === "replication" ? "# Used by `npx ablo connect` to set up + register logical replication \u2014 the\n# DIRECT (un-pooled) endpoint. Ablo TAILS your WAL from here; it never writes.\n# The client never sees it; the browser never sees it. Your DB stays yours.\nDATABASE_URL=postgres://user:password@host:5432/db\n" : "# Used by ablo/data-source.ts (your DB endpoint) + `ablo migrate` \u2014 NOT the client.\n# Ablo never sees it; the browser never sees it. Your DB stays in your app.\nDATABASE_URL=postgres://user:password@host:5432/db\n";
|
|
284409
284702
|
const webhookBlock = storage === "endpoint" ? "# Signing secret for the webhook receiver (app/api/ablo/webhooks/route.ts).\n# Ablo mints this when you register the endpoint's URL (POST /v1/webhook_endpoints\n# or the dashboard) and returns it once \u2014 paste it here.\nABLO_WEBHOOK_SECRET=whsec_your_endpoint_secret_here\n" : "";
|
|
284410
284703
|
const apiKeyBlock = includeApiKey ? "# Ablo Sync Engine \u2014 use a sk_test_ key for local dev (`npx ablo push`)\nABLO_API_KEY=sk_test_your_key_here\n" : "";
|
|
284411
284704
|
return `${apiKeyBlock}${webhookBlock}${databaseBlock}`;
|