@abloatai/ablo 0.6.0 → 0.7.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 +45 -0
- package/README.md +64 -35
- package/dist/BaseSyncedStore.d.ts +1 -1
- package/dist/BaseSyncedStore.js +1 -1
- package/dist/client/Ablo.d.ts +1 -0
- package/dist/client/Ablo.js +1 -0
- package/dist/client/createModelProxy.d.ts +26 -3
- package/dist/client/createModelProxy.js +4 -1
- package/dist/client/validateAbloOptions.js +2 -2
- package/dist/coordination/index.d.ts +6 -0
- package/dist/coordination/index.js +6 -0
- package/dist/coordination/schema.d.ts +329 -0
- package/dist/coordination/schema.js +209 -0
- package/dist/core/QueryView.d.ts +4 -1
- package/dist/core/QueryView.js +1 -1
- package/dist/core/query-utils.d.ts +7 -10
- package/dist/core/query-utils.js +2 -3
- package/dist/errorCodes.d.ts +264 -0
- package/dist/errorCodes.js +251 -0
- package/dist/errors.d.ts +51 -6
- package/dist/errors.js +56 -3
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -2
- package/dist/policy/index.d.ts +1 -1
- package/dist/policy/index.js +1 -1
- package/dist/policy/types.d.ts +31 -0
- package/dist/policy/types.js +15 -0
- package/dist/react/AbloProvider.d.ts +12 -0
- package/dist/react/AbloProvider.js +11 -3
- package/dist/schema/ddl.d.ts +62 -0
- package/dist/schema/ddl.js +317 -0
- package/dist/schema/diff.d.ts +6 -0
- package/dist/schema/diff.js +21 -3
- package/dist/schema/field.d.ts +16 -19
- package/dist/schema/field.js +30 -17
- package/dist/schema/index.d.ts +7 -4
- package/dist/schema/index.js +9 -3
- package/dist/schema/model.d.ts +87 -25
- package/dist/schema/model.js +33 -3
- package/dist/schema/relation.d.ts +17 -0
- package/dist/schema/roles.d.ts +148 -0
- package/dist/schema/roles.js +149 -0
- package/dist/schema/schema.d.ts +2 -112
- package/dist/schema/schema.js +50 -62
- package/dist/schema/select.d.ts +25 -0
- package/dist/schema/select.js +55 -0
- package/dist/schema/serialize.d.ts +13 -9
- package/dist/schema/serialize.js +14 -10
- package/dist/schema/sugar.d.ts +20 -3
- package/dist/schema/sugar.js +5 -1
- package/dist/schema/tenancy.d.ts +66 -0
- package/dist/schema/tenancy.js +58 -0
- package/dist/sync/HydrationCoordinator.d.ts +2 -0
- package/dist/sync/HydrationCoordinator.js +23 -17
- package/dist/sync/createIntentStream.d.ts +2 -1
- package/dist/sync/createIntentStream.js +46 -1
- package/dist/sync/participants.js +5 -14
- package/dist/types/streams.d.ts +53 -33
- package/docs/api-keys.md +44 -0
- package/docs/api.md +11 -22
- package/docs/cli.md +212 -0
- package/docs/client-behavior.md +1 -1
- package/docs/coordination.md +61 -12
- package/docs/data-sources.md +2 -2
- package/docs/examples/existing-python-backend.md +3 -3
- package/docs/examples/scoped-agent.md +78 -0
- package/docs/guarantees.md +5 -2
- package/docs/identity.md +139 -68
- package/docs/index.md +6 -0
- package/docs/integration-guide.md +31 -35
- package/docs/interaction-model.md +3 -0
- package/docs/react.md +3 -3
- package/docs/roadmap.md +14 -2
- package/package.json +8 -1
package/dist/errors.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*
|
|
19
19
|
* Both work on every subclass.
|
|
20
20
|
*/
|
|
21
|
+
export { ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode } from './errorCodes.js';
|
|
21
22
|
// ── AbloError hierarchy — the typed error surface ────────────────────
|
|
22
23
|
/** Common shape for all errors thrown by this SDK. */
|
|
23
24
|
export class AbloError extends Error {
|
|
@@ -25,14 +26,32 @@ export class AbloError extends Error {
|
|
|
25
26
|
* switch on `e.type` without `instanceof` checks across package
|
|
26
27
|
* boundaries (matches Stripe's `err.type` pattern). */
|
|
27
28
|
type = 'AbloError';
|
|
28
|
-
/** Stable short identifier for logs + metrics
|
|
29
|
-
*
|
|
29
|
+
/** Stable short identifier for logs + metrics, drawn from the closed
|
|
30
|
+
* {@link ErrorCode} registry — e.g. `'apikey_invalid'`,
|
|
31
|
+
* `'capability_scope_denied'`. Stored as a plain `string` (not
|
|
32
|
+
* `ErrorCode`) so an older SDK still surfaces a newer server's code it
|
|
33
|
+
* doesn't recognise yet; producers are constrained at the constructor
|
|
34
|
+
* param instead. */
|
|
30
35
|
code;
|
|
31
36
|
/** HTTP status code when the error originated from an HTTP response. */
|
|
32
37
|
httpStatus;
|
|
33
38
|
/** Correlation id for ops — present when the server sent one on
|
|
34
39
|
* `x-request-id`. Include in support tickets. */
|
|
35
40
|
requestId;
|
|
41
|
+
/** Which input caused the error — a model/field path like
|
|
42
|
+
* `'dataroomMember.grants.subject'`. Mirrors Stripe's `error.param`;
|
|
43
|
+
* lets tooling point at the exact offending declaration. */
|
|
44
|
+
param;
|
|
45
|
+
/** Link to the docs for this `code`. Mirrors Stripe's `error.doc_url`.
|
|
46
|
+
* Defaults from `code` via {@link docUrlForCode} when omitted. */
|
|
47
|
+
docUrl;
|
|
48
|
+
/** Domain-specific structured payload merged into the wire envelope —
|
|
49
|
+
* e.g. a schema push's `{ warnings, unexecutable }`, a stale write's
|
|
50
|
+
* conflicting rows. Mirrors how Stripe attaches type-specific fields
|
|
51
|
+
* (`decline_code`, `payment_intent`) alongside the standard ones, so a
|
|
52
|
+
* structured error keeps its detail through `toJSON` instead of being
|
|
53
|
+
* flattened to a bare message. */
|
|
54
|
+
details;
|
|
36
55
|
constructor(message, options) {
|
|
37
56
|
super(message);
|
|
38
57
|
this.name = this.constructor.name;
|
|
@@ -42,10 +61,41 @@ export class AbloError extends Error {
|
|
|
42
61
|
this.httpStatus = options.httpStatus;
|
|
43
62
|
if (options?.requestId !== undefined)
|
|
44
63
|
this.requestId = options.requestId;
|
|
64
|
+
if (options?.param !== undefined)
|
|
65
|
+
this.param = options.param;
|
|
66
|
+
if (options?.details !== undefined)
|
|
67
|
+
this.details = options.details;
|
|
68
|
+
const docUrl = options?.docUrl ?? (options?.code ? docUrlForCode(options.code) : undefined);
|
|
69
|
+
if (docUrl !== undefined)
|
|
70
|
+
this.docUrl = docUrl;
|
|
45
71
|
if (options?.cause !== undefined) {
|
|
46
72
|
Object.defineProperty(this, 'cause', { value: options.cause, enumerable: false });
|
|
47
73
|
}
|
|
48
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Serialize to Stripe's error-object shape: `{ type, code, param, message,
|
|
77
|
+
* doc_url, request_id }`. One JSON shape across HTTP bodies, WS frames, and
|
|
78
|
+
* logs — so consumers parse Ablo errors the way they already parse Stripe's.
|
|
79
|
+
*/
|
|
80
|
+
toJSON() {
|
|
81
|
+
return {
|
|
82
|
+
type: this.type,
|
|
83
|
+
...(this.code !== undefined ? { code: this.code } : {}),
|
|
84
|
+
...(this.param !== undefined ? { param: this.param } : {}),
|
|
85
|
+
message: this.message,
|
|
86
|
+
...(this.docUrl !== undefined ? { doc_url: this.docUrl } : {}),
|
|
87
|
+
...(this.requestId !== undefined ? { request_id: this.requestId } : {}),
|
|
88
|
+
...(this.details ?? {}),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Map a stable error `code` to its docs URL — the one place the convention
|
|
94
|
+
* lives, so every error carrying a code gets a `doc_url` for free (Stripe
|
|
95
|
+
* ships a link on every error).
|
|
96
|
+
*/
|
|
97
|
+
export function docUrlForCode(code) {
|
|
98
|
+
return `https://docs.abloatai.com/errors#${code}`;
|
|
49
99
|
}
|
|
50
100
|
/** 401 — invalid/missing/expired credentials. */
|
|
51
101
|
export class AbloAuthenticationError extends AbloError {
|
|
@@ -224,7 +274,10 @@ export function translateHttpError(status, body, requestId) {
|
|
|
224
274
|
flatError ??
|
|
225
275
|
(typeof body === 'string' ? body : `HTTP ${status}`);
|
|
226
276
|
const requiredCapability = nested?.requiredCapability ?? parsed.requiredCapability;
|
|
227
|
-
|
|
277
|
+
// Wire boundary: an incoming code is an arbitrary string (a newer server
|
|
278
|
+
// may send a code this SDK predates). Cast to ErrorCode here — the one
|
|
279
|
+
// sanctioned crossing — so internal producers stay statically checked.
|
|
280
|
+
const publicCode = (code === 'intent_conflict' ? 'claim_conflict' : code);
|
|
228
281
|
const baseOpts = { code: publicCode, httpStatus: status, requestId };
|
|
229
282
|
if (status === 401)
|
|
230
283
|
return new AbloAuthenticationError(message, baseOpts);
|
package/dist/index.d.ts
CHANGED
|
@@ -48,9 +48,10 @@ export { session, agent } from './principal.js';
|
|
|
48
48
|
import { Ablo } from './client/Ablo.js';
|
|
49
49
|
export default Ablo;
|
|
50
50
|
export { dataSource, abloSource, signAbloSourceRequest, verifyAbloSourceRequest, } from './source/index.js';
|
|
51
|
-
export { defaultPolicy } from './policy/index.js';
|
|
52
|
-
export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, } from './errors.js';
|
|
51
|
+
export { defaultPolicy, capabilityPreemptPolicy } from './policy/index.js';
|
|
52
|
+
export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, } from './errors.js';
|
|
53
53
|
export type { CommitReceipt, RequiredCapability } from './errors.js';
|
|
54
|
+
export type { ErrorCode, WireErrorCode, ErrorCategory, ErrorCodeSpec } from './errors.js';
|
|
54
55
|
export type { Register, DefaultSyncShape } from './types/global.js';
|
|
55
56
|
export { defineMutators } from './mutators/defineMutators.js';
|
|
56
57
|
export { createTransaction, type Transaction } from './mutators/Transaction.js';
|
package/dist/index.js
CHANGED
|
@@ -74,11 +74,11 @@ export { dataSource, abloSource, signAbloSourceRequest, verifyAbloSourceRequest,
|
|
|
74
74
|
// (reject-on-stale) is already applied server-side, so you only import it
|
|
75
75
|
// to COMPOSE a custom policy. Leave it alone and stale writes are rejected
|
|
76
76
|
// safely by default. Type counterparts live under `Ablo.Conflict.*`.
|
|
77
|
-
export { defaultPolicy } from './policy/index.js';
|
|
77
|
+
export { defaultPolicy, capabilityPreemptPolicy } from './policy/index.js';
|
|
78
78
|
// Typed error hierarchy — Stripe-style. One import gets every class
|
|
79
79
|
// consumers need to discriminate failures (`e instanceof AbloX` or
|
|
80
80
|
// `e.type === 'AbloX'`) plus the HTTP-response translator.
|
|
81
|
-
export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, } from './errors.js';
|
|
81
|
+
export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, } from './errors.js';
|
|
82
82
|
// Advanced — most apps never import this. Custom (Zero-style) mutators:
|
|
83
83
|
// `ablo.<model>.create/update/delete` already covers normal writes. Reach
|
|
84
84
|
// for `defineMutators` only when you need a named, multi-step mutation with
|
package/dist/policy/index.d.ts
CHANGED
|
@@ -16,4 +16,4 @@
|
|
|
16
16
|
* ```
|
|
17
17
|
*/
|
|
18
18
|
export type { Conflict, ConflictDecision, ConflictKind, ConflictOperation, ConflictPolicy, StaleContextConflict, IntentHeldConflict, } from './types.js';
|
|
19
|
-
export { defaultPolicy } from './types.js';
|
|
19
|
+
export { defaultPolicy, capabilityPreemptPolicy } from './types.js';
|
package/dist/policy/index.js
CHANGED
package/dist/policy/types.d.ts
CHANGED
|
@@ -48,6 +48,14 @@ export interface IntentHeldConflict extends ConflictBase {
|
|
|
48
48
|
readonly entityId: string;
|
|
49
49
|
/** Holder's intent expiry (ms since epoch). */
|
|
50
50
|
readonly expiresAt: number;
|
|
51
|
+
/**
|
|
52
|
+
* The committer's granted capability operations (the key's allowlist). A
|
|
53
|
+
* policy is a pure function of the conflict value, so it can only authorize
|
|
54
|
+
* on what's carried here — this is what lets a policy express "preempt iff
|
|
55
|
+
* the committer holds `intent.preempt`" (see `capabilityPreemptPolicy`).
|
|
56
|
+
* Empty for a human session with no allowlist.
|
|
57
|
+
*/
|
|
58
|
+
readonly committerOperations: readonly string[];
|
|
51
59
|
}
|
|
52
60
|
/**
|
|
53
61
|
* The discriminated union the policy receives. Switch on `.kind` to
|
|
@@ -61,6 +69,20 @@ export type ConflictDecision = {
|
|
|
61
69
|
} | {
|
|
62
70
|
readonly action: 'allow';
|
|
63
71
|
readonly note?: string;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Evict the current holder and grant the target to the committer. Only
|
|
75
|
+
* meaningful for an `intent_held` conflict at claim time (`intent_begin`):
|
|
76
|
+
* the holder receives an `intent_lost` (reason `'preempted'`) and the
|
|
77
|
+
* preemptor takes the lease, jumping ahead of any FIFO waiters. This is the
|
|
78
|
+
* authorization seam for preemption — a policy returns `preempt` only for a
|
|
79
|
+
* committer it deems higher-priority (e.g. a supervisor over its sub-agents,
|
|
80
|
+
* or an identity holding a preempt capability). At commit time there is no
|
|
81
|
+
* holder to evict, so a `preempt` decision there is treated as `allow`.
|
|
82
|
+
*/
|
|
83
|
+
| {
|
|
84
|
+
readonly action: 'preempt';
|
|
85
|
+
readonly reason?: string;
|
|
64
86
|
};
|
|
65
87
|
/**
|
|
66
88
|
* Pluggable decision function. Sync or async.
|
|
@@ -81,4 +103,13 @@ export type ConflictPolicy = (conflict: Conflict) => ConflictDecision | Promise<
|
|
|
81
103
|
* intent-conflicting write through.
|
|
82
104
|
*/
|
|
83
105
|
export declare const defaultPolicy: ConflictPolicy;
|
|
106
|
+
/**
|
|
107
|
+
* Capability-gated preemption. An `intent_held` conflict is PREEMPTED when the
|
|
108
|
+
* committer holds the `intent.preempt` operation in its capability allowlist
|
|
109
|
+
* (the holder is evicted, the committer takes the lease); everything else falls
|
|
110
|
+
* back to `defaultPolicy` (reject). Opt-in — wire it as a `conflictPolicies`
|
|
111
|
+
* global to let a privileged identity jump a held entity without a bespoke
|
|
112
|
+
* policy. The authorization is the capability, not an identity string.
|
|
113
|
+
*/
|
|
114
|
+
export declare const capabilityPreemptPolicy: ConflictPolicy;
|
|
84
115
|
export {};
|
package/dist/policy/types.js
CHANGED
|
@@ -15,3 +15,18 @@ export const defaultPolicy = (conflict) => ({
|
|
|
15
15
|
action: 'reject',
|
|
16
16
|
reason: conflict.kind === 'stale_context' ? 'stale_context' : 'intent_conflict',
|
|
17
17
|
});
|
|
18
|
+
/**
|
|
19
|
+
* Capability-gated preemption. An `intent_held` conflict is PREEMPTED when the
|
|
20
|
+
* committer holds the `intent.preempt` operation in its capability allowlist
|
|
21
|
+
* (the holder is evicted, the committer takes the lease); everything else falls
|
|
22
|
+
* back to `defaultPolicy` (reject). Opt-in — wire it as a `conflictPolicies`
|
|
23
|
+
* global to let a privileged identity jump a held entity without a bespoke
|
|
24
|
+
* policy. The authorization is the capability, not an identity string.
|
|
25
|
+
*/
|
|
26
|
+
export const capabilityPreemptPolicy = (conflict) => {
|
|
27
|
+
if (conflict.kind === 'intent_held' &&
|
|
28
|
+
conflict.committerOperations.includes('intent.preempt')) {
|
|
29
|
+
return { action: 'preempt', reason: 'capability:intent.preempt' };
|
|
30
|
+
}
|
|
31
|
+
return defaultPolicy(conflict);
|
|
32
|
+
};
|
|
@@ -114,7 +114,19 @@ export interface AbloProviderProps<R extends SchemaRecord = SchemaRecord> {
|
|
|
114
114
|
sessionErrorDetector?: SessionErrorDetector;
|
|
115
115
|
onlineStatus?: OnlineStatusProvider;
|
|
116
116
|
configOverrides?: SyncEngineConfig;
|
|
117
|
+
/**
|
|
118
|
+
* Raw sync-group strings for the initial connection. Prefer {@link scope} —
|
|
119
|
+
* the model form (`{ decks: deckId }`) that the engine resolves through the
|
|
120
|
+
* schema's `scope`, so you never hand-write a `deck:<id>` string. Both merge.
|
|
121
|
+
*/
|
|
117
122
|
syncGroups?: string[];
|
|
123
|
+
/**
|
|
124
|
+
* Model-form connection scope: `{ decks: deckId, documents: documentId }` or
|
|
125
|
+
* entity refs. Resolved through the schema's per-model `scope` into group
|
|
126
|
+
* strings (so typename `SlideDeck` → `deck:<id>`), unioned with {@link syncGroups}.
|
|
127
|
+
* Memoize the object if it's derived, to avoid rotating the engine each render.
|
|
128
|
+
*/
|
|
129
|
+
scope?: ParticipantScope;
|
|
118
130
|
bootstrapBaseUrl?: string;
|
|
119
131
|
maxPoolSize?: number;
|
|
120
132
|
/**
|
|
@@ -32,7 +32,7 @@ function createErrorEmitter() {
|
|
|
32
32
|
};
|
|
33
33
|
}
|
|
34
34
|
export function AbloProvider(props) {
|
|
35
|
-
const { schema, url = 'wss://mesh.ablo.finance', userId, teamIds, apiKey, preventUnsavedChanges, onSessionExpired, onError, observability, logger, mutationExecutor, mutationDispatcher, sessionErrorDetector, onlineStatus, configOverrides, syncGroups, bootstrapBaseUrl, maxPoolSize, persistence, bootstrapMode, fallback = _jsx(DefaultFallback, {}), children, } = props;
|
|
35
|
+
const { schema, url = 'wss://mesh.ablo.finance', userId, teamIds, apiKey, preventUnsavedChanges, onSessionExpired, onError, observability, logger, mutationExecutor, mutationDispatcher, sessionErrorDetector, onlineStatus, configOverrides, syncGroups, scope, bootstrapBaseUrl, maxPoolSize, persistence, bootstrapMode, fallback = _jsx(DefaultFallback, {}), children, } = props;
|
|
36
36
|
// Account scope is no longer accepted from props. The engine learns
|
|
37
37
|
// it from auth (capability token) at bootstrap and we read it back
|
|
38
38
|
// out of `_store.orgId` once `engine.ready()` resolves.
|
|
@@ -86,7 +86,11 @@ export function AbloProvider(props) {
|
|
|
86
86
|
mutationExecutor,
|
|
87
87
|
mutationDispatcher,
|
|
88
88
|
configOverrides,
|
|
89
|
-
|
|
89
|
+
// Union raw strings with model-form `scope` resolved through the schema,
|
|
90
|
+
// so `scope={{ decks: id }}` becomes `deck:<id>` via the model's `scope`.
|
|
91
|
+
syncGroups: scope
|
|
92
|
+
? [...(syncGroups ?? []), ...resolveParticipantSyncGroups(scope, schema)]
|
|
93
|
+
: syncGroups,
|
|
90
94
|
bootstrapBaseUrl,
|
|
91
95
|
maxPoolSize,
|
|
92
96
|
persistence,
|
|
@@ -251,7 +255,11 @@ export function useParticipant(opts) {
|
|
|
251
255
|
const ctx = useContext(AbloInternalContext);
|
|
252
256
|
const engine = ctx?.engine ?? null;
|
|
253
257
|
const { paused = false } = opts;
|
|
254
|
-
|
|
258
|
+
// Resolve the model-form scope ({ decks: id } / refs) THROUGH the schema, so a
|
|
259
|
+
// model's declared `scope` kind is honored (typename `SlideDeck` → `deck:<id>`,
|
|
260
|
+
// not the `type:id` string fallback). Schema appears once the engine is ready;
|
|
261
|
+
// until then refs resolve by convention, then re-resolve when it arrives.
|
|
262
|
+
const scopeKey = JSON.stringify(resolveParticipantSyncGroups(opts.scope, engine?.schema).sort());
|
|
255
263
|
const scopedSyncGroups = useMemo(() => JSON.parse(scopeKey), [scopeKey]);
|
|
256
264
|
const [claimError, setClaimError] = useState(null);
|
|
257
265
|
const [claimConnected, setClaimConnected] = useState(false);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema → Postgres DDL — the one pure SQL emitter shared by every consumer.
|
|
3
|
+
*
|
|
4
|
+
* `defineSchema(...)` (serialized to {@link SchemaJSON}) is the single source of
|
|
5
|
+
* truth; this module lowers it to ordered DDL strings. Both the hosted server
|
|
6
|
+
* (which applies it to Ablo-managed Postgres on `schema push`) and the
|
|
7
|
+
* `ablo migrate` CLI (which applies it to a customer's own Postgres) call these
|
|
8
|
+
* generators, so the SQL — column types, RLS, enum checks — is identical no
|
|
9
|
+
* matter who runs it. There is no second type map.
|
|
10
|
+
*
|
|
11
|
+
* Everything here is pure (returns strings; no DB, no I/O); the execution side
|
|
12
|
+
* (transaction + advisory lock) lives with each consumer because it's coupled
|
|
13
|
+
* to that consumer's Postgres client and error type.
|
|
14
|
+
*
|
|
15
|
+
* - `generateProvisionPlan` — additive + idempotent (CREATE/ADD … IF NOT
|
|
16
|
+
* EXISTS + RLS). Never loses data. The "create my tables" primitive.
|
|
17
|
+
* - `generateMigrationPlan` — the destructive-aware counterpart driven by the
|
|
18
|
+
* {@link diffSchema} step list (drops, renames, type casts, backfills).
|
|
19
|
+
*/
|
|
20
|
+
import type { SchemaJSON, ModelJSON } from './serialize.js';
|
|
21
|
+
import type { MigrationStep, BackfillValue } from './diff.js';
|
|
22
|
+
export interface ProvisionPlan {
|
|
23
|
+
/** The Postgres schema the tables live in (`app_<id>` or `public`). */
|
|
24
|
+
readonly appSchema: string;
|
|
25
|
+
/** Ordered, idempotent DDL statements. Safe to run repeatedly. */
|
|
26
|
+
readonly statements: readonly string[];
|
|
27
|
+
}
|
|
28
|
+
export interface MigrationPlan {
|
|
29
|
+
/** The app Postgres schema the DDL targets (`app_<id>` or `public`). */
|
|
30
|
+
readonly appSchema: string;
|
|
31
|
+
/** Ordered DDL statements (expand → contract). */
|
|
32
|
+
readonly statements: readonly string[];
|
|
33
|
+
}
|
|
34
|
+
/** Per-app schema name for an app (organization) id. */
|
|
35
|
+
export declare function appSchemaName(organizationId: string): string;
|
|
36
|
+
export declare function camelToSnake(identifier: string): string;
|
|
37
|
+
/** Quote an identifier (defense-in-depth; inputs are already slug/snake). */
|
|
38
|
+
export declare function q(identifier: string): string;
|
|
39
|
+
export declare function sqlType(fieldType: ModelJSON['fields'][string]['type']): string;
|
|
40
|
+
/**
|
|
41
|
+
* Build the additive, idempotent provisioning plan for an app. Pure — no DB
|
|
42
|
+
* access.
|
|
43
|
+
*
|
|
44
|
+
* `targetSchema` is where the tables live: the app's schema `app_<id>` on the
|
|
45
|
+
* shared tier, or `public` on a dedicated tenant's own database (where the DB
|
|
46
|
+
* itself is the isolation boundary). For `public` the `CREATE SCHEMA` is
|
|
47
|
+
* skipped (it always exists).
|
|
48
|
+
*/
|
|
49
|
+
export declare function generateProvisionPlan(schema: SchemaJSON, targetSchema: string): ProvisionPlan;
|
|
50
|
+
/**
|
|
51
|
+
* Lower an ordered migration step list to DDL. `next` is the schema being pushed
|
|
52
|
+
* (the target column shapes are read from it), `prev` the active one (used to
|
|
53
|
+
* resolve the *old* table name on a model rename).
|
|
54
|
+
*/
|
|
55
|
+
export declare function generateMigrationPlan(steps: readonly MigrationStep[], opts: {
|
|
56
|
+
readonly prev: SchemaJSON | null;
|
|
57
|
+
readonly next: SchemaJSON;
|
|
58
|
+
readonly targetSchema: string;
|
|
59
|
+
/** Constant seed values that let a required-field add / made-required step
|
|
60
|
+
* set NOT NULL on a non-empty table. Keyed by (model, field). */
|
|
61
|
+
readonly backfills?: readonly BackfillValue[];
|
|
62
|
+
}): MigrationPlan;
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema → Postgres DDL — the one pure SQL emitter shared by every consumer.
|
|
3
|
+
*
|
|
4
|
+
* `defineSchema(...)` (serialized to {@link SchemaJSON}) is the single source of
|
|
5
|
+
* truth; this module lowers it to ordered DDL strings. Both the hosted server
|
|
6
|
+
* (which applies it to Ablo-managed Postgres on `schema push`) and the
|
|
7
|
+
* `ablo migrate` CLI (which applies it to a customer's own Postgres) call these
|
|
8
|
+
* generators, so the SQL — column types, RLS, enum checks — is identical no
|
|
9
|
+
* matter who runs it. There is no second type map.
|
|
10
|
+
*
|
|
11
|
+
* Everything here is pure (returns strings; no DB, no I/O); the execution side
|
|
12
|
+
* (transaction + advisory lock) lives with each consumer because it's coupled
|
|
13
|
+
* to that consumer's Postgres client and error type.
|
|
14
|
+
*
|
|
15
|
+
* - `generateProvisionPlan` — additive + idempotent (CREATE/ADD … IF NOT
|
|
16
|
+
* EXISTS + RLS). Never loses data. The "create my tables" primitive.
|
|
17
|
+
* - `generateMigrationPlan` — the destructive-aware counterpart driven by the
|
|
18
|
+
* {@link diffSchema} step list (drops, renames, type casts, backfills).
|
|
19
|
+
*/
|
|
20
|
+
import { resolveTenancy, tenancyColumn } from './tenancy.js';
|
|
21
|
+
// ── Identifier safety ────────────────────────────────────────────────────────
|
|
22
|
+
/** Postgres unquoted-identifier-safe slug: lowercase `[a-z0-9_]`, ≤50 chars. */
|
|
23
|
+
function slug(raw) {
|
|
24
|
+
const s = raw.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
|
25
|
+
return s.slice(0, 50) || 'x';
|
|
26
|
+
}
|
|
27
|
+
/** Per-app schema name for an app (organization) id. */
|
|
28
|
+
export function appSchemaName(organizationId) {
|
|
29
|
+
return `app_${slug(organizationId)}`;
|
|
30
|
+
}
|
|
31
|
+
export function camelToSnake(identifier) {
|
|
32
|
+
return identifier.replace(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`);
|
|
33
|
+
}
|
|
34
|
+
/** Quote an identifier (defense-in-depth; inputs are already slug/snake). */
|
|
35
|
+
export function q(identifier) {
|
|
36
|
+
return `"${identifier.replace(/"/g, '""')}"`;
|
|
37
|
+
}
|
|
38
|
+
// ── Field type mapping ───────────────────────────────────────────────────────
|
|
39
|
+
export function sqlType(fieldType) {
|
|
40
|
+
switch (fieldType) {
|
|
41
|
+
case 'string':
|
|
42
|
+
case 'enum':
|
|
43
|
+
return 'TEXT';
|
|
44
|
+
case 'number':
|
|
45
|
+
// DOUBLE PRECISION, not INTEGER — a Zod `number` may be fractional and
|
|
46
|
+
// truncating to INTEGER is silent data loss.
|
|
47
|
+
return 'DOUBLE PRECISION';
|
|
48
|
+
case 'boolean':
|
|
49
|
+
return 'BOOLEAN';
|
|
50
|
+
case 'date':
|
|
51
|
+
return 'TIMESTAMPTZ';
|
|
52
|
+
case 'json':
|
|
53
|
+
default:
|
|
54
|
+
return 'JSONB';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const BASE_COLUMNS = new Set(['id', 'organization_id', 'created_by', 'created_at', 'updated_at']);
|
|
58
|
+
// ── Provisioning (additive, idempotent) ─────────────────────────────────────
|
|
59
|
+
/**
|
|
60
|
+
* Build the additive, idempotent provisioning plan for an app. Pure — no DB
|
|
61
|
+
* access.
|
|
62
|
+
*
|
|
63
|
+
* `targetSchema` is where the tables live: the app's schema `app_<id>` on the
|
|
64
|
+
* shared tier, or `public` on a dedicated tenant's own database (where the DB
|
|
65
|
+
* itself is the isolation boundary). For `public` the `CREATE SCHEMA` is
|
|
66
|
+
* skipped (it always exists).
|
|
67
|
+
*/
|
|
68
|
+
export function generateProvisionPlan(schema, targetSchema) {
|
|
69
|
+
const appSchema = targetSchema;
|
|
70
|
+
const qs = q(appSchema);
|
|
71
|
+
const statements = appSchema === 'public' ? [] : [`CREATE SCHEMA IF NOT EXISTS ${qs};`];
|
|
72
|
+
for (const [key, model] of Object.entries(schema.models)) {
|
|
73
|
+
// Default the physical table to the model key when `tableName` is omitted —
|
|
74
|
+
// same fallback the migration path uses (`tableOfModel: m.tableName ?? key`).
|
|
75
|
+
// Without this, a schema that doesn't set `tableName` (e.g. the `ablo init`
|
|
76
|
+
// starter) provisions zero tables.
|
|
77
|
+
const table = model.tableName ?? key;
|
|
78
|
+
const qt = `${qs}.${q(table)}`;
|
|
79
|
+
// Base columns are schema-driven, not blanket. `organization_id` (and its
|
|
80
|
+
// index + tenant-isolation RLS below) is emitted only for org-scoped models.
|
|
81
|
+
// A model that declares `orgScoped: false` (users, organizations, and other
|
|
82
|
+
// tables scoped via a FK / app layer) genuinely has no `organization_id`
|
|
83
|
+
// column — forcing one would add a NOT NULL column that fails on existing
|
|
84
|
+
// rows and contradicts the model's own declaration.
|
|
85
|
+
// Tenancy column: present only for column-scoped models, with the
|
|
86
|
+
// configured name (default `organization_id`). `parent`/`none` tenancy emit
|
|
87
|
+
// no tenancy column — they're scoped via a parent FK or not at all.
|
|
88
|
+
const orgCol = tenancyColumn(resolveTenancy(model));
|
|
89
|
+
const baseColumns = [
|
|
90
|
+
` ${q('id')} TEXT PRIMARY KEY,`,
|
|
91
|
+
...(orgCol ? [` ${q(orgCol)} TEXT NOT NULL,`] : []),
|
|
92
|
+
` ${q('created_by')} TEXT,`,
|
|
93
|
+
` ${q('created_at')} TIMESTAMPTZ NOT NULL DEFAULT NOW(),`,
|
|
94
|
+
` ${q('updated_at')} TIMESTAMPTZ NOT NULL DEFAULT NOW()`,
|
|
95
|
+
];
|
|
96
|
+
statements.push(`CREATE TABLE IF NOT EXISTS ${qt} (\n${baseColumns.join('\n')}\n);`);
|
|
97
|
+
for (const [fieldName, meta] of Object.entries(model.fields)) {
|
|
98
|
+
const col = meta.column ?? camelToSnake(fieldName);
|
|
99
|
+
if (BASE_COLUMNS.has(col) || col === orgCol)
|
|
100
|
+
continue;
|
|
101
|
+
statements.push(`ALTER TABLE ${qt} ADD COLUMN IF NOT EXISTS ${q(col)} ${sqlType(meta.type)};`);
|
|
102
|
+
if (meta.type === 'enum' && meta.enumValues && meta.enumValues.length > 0) {
|
|
103
|
+
const cname = `${table}_${col}_enum`;
|
|
104
|
+
const allowed = meta.enumValues.map((v) => `'${v.replace(/'/g, "''")}'`).join(', ');
|
|
105
|
+
statements.push(`DO $$ BEGIN\n` +
|
|
106
|
+
` IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = '${cname}') THEN\n` +
|
|
107
|
+
` ALTER TABLE ${qt} ADD CONSTRAINT ${q(cname)} CHECK (${q(col)} IN (${allowed}));\n` +
|
|
108
|
+
` END IF;\n` +
|
|
109
|
+
`END $$;`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Org index + tenant-isolation RLS only where there's an `organization_id`
|
|
113
|
+
// to isolate on. Non-org-scoped tables rely on FK/app-layer scoping.
|
|
114
|
+
if (orgCol) {
|
|
115
|
+
statements.push(`CREATE INDEX IF NOT EXISTS ${q(`${table}_${orgCol}_idx`)} ON ${qt} (${q(orgCol)});`);
|
|
116
|
+
statements.push(`ALTER TABLE ${qt} ENABLE ROW LEVEL SECURITY;`);
|
|
117
|
+
statements.push(`ALTER TABLE ${qt} FORCE ROW LEVEL SECURITY;`);
|
|
118
|
+
const policy = `${table}_tenant_isolation`;
|
|
119
|
+
const predicate = `${q(orgCol)} = current_setting('app.current_org_id', true)`;
|
|
120
|
+
statements.push(`DROP POLICY IF EXISTS ${q(policy)} ON ${qt};`);
|
|
121
|
+
statements.push(`CREATE POLICY ${q(policy)} ON ${qt}\n USING (${predicate})\n WITH CHECK (${predicate});`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { appSchema, statements };
|
|
125
|
+
}
|
|
126
|
+
// ── Migration (destructive-aware, diff-driven) ──────────────────────────────
|
|
127
|
+
function enumCheckStatements(table, col, qt, values) {
|
|
128
|
+
const cname = `${table}_${col}_enum`;
|
|
129
|
+
const stmts = [`ALTER TABLE ${qt} DROP CONSTRAINT IF EXISTS ${q(cname)};`];
|
|
130
|
+
if (values.length > 0) {
|
|
131
|
+
const allowed = values.map((v) => `'${v.replace(/'/g, "''")}'`).join(', ');
|
|
132
|
+
stmts.push(`ALTER TABLE ${qt} ADD CONSTRAINT ${q(cname)} CHECK (${q(col)} IN (${allowed}));`);
|
|
133
|
+
}
|
|
134
|
+
return stmts;
|
|
135
|
+
}
|
|
136
|
+
function indexName(table, col) {
|
|
137
|
+
return `${table}_${col}_idx`;
|
|
138
|
+
}
|
|
139
|
+
function columnNameOf(fieldName, meta) {
|
|
140
|
+
return meta?.column ?? camelToSnake(fieldName);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Encode a constant backfill value as a typed SQL literal. Inputs are operator-
|
|
144
|
+
* supplied (via the authed push), but we still encode by the field's declared
|
|
145
|
+
* type and escape strings rather than interpolate raw — defense-in-depth.
|
|
146
|
+
*/
|
|
147
|
+
function sqlLiteral(value, fieldType) {
|
|
148
|
+
switch (fieldType) {
|
|
149
|
+
case 'number':
|
|
150
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
151
|
+
throw new Error(`backfill for a number field must be a finite number, got ${JSON.stringify(value)}`);
|
|
152
|
+
}
|
|
153
|
+
return String(value);
|
|
154
|
+
case 'boolean':
|
|
155
|
+
return value ? 'TRUE' : 'FALSE';
|
|
156
|
+
case 'date':
|
|
157
|
+
return `'${String(value).replace(/'/g, "''")}'::timestamptz`;
|
|
158
|
+
case 'json':
|
|
159
|
+
return `'${JSON.stringify(value).replace(/'/g, "''")}'::jsonb`;
|
|
160
|
+
case 'string':
|
|
161
|
+
case 'enum':
|
|
162
|
+
default:
|
|
163
|
+
return `'${String(value).replace(/'/g, "''")}'`;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Lower an ordered migration step list to DDL. `next` is the schema being pushed
|
|
168
|
+
* (the target column shapes are read from it), `prev` the active one (used to
|
|
169
|
+
* resolve the *old* table name on a model rename).
|
|
170
|
+
*/
|
|
171
|
+
export function generateMigrationPlan(steps, opts) {
|
|
172
|
+
const { prev, next, targetSchema, backfills = [] } = opts;
|
|
173
|
+
const qs = q(targetSchema);
|
|
174
|
+
const statements = [];
|
|
175
|
+
const qtFor = (table) => `${qs}.${q(table)}`;
|
|
176
|
+
const tableOfModel = (schema, key) => {
|
|
177
|
+
const m = schema?.models[key];
|
|
178
|
+
if (!m)
|
|
179
|
+
return null;
|
|
180
|
+
return m.tableName ?? key;
|
|
181
|
+
};
|
|
182
|
+
const backfillFor = (model, field) => backfills.find((b) => b.model === model && b.field === field);
|
|
183
|
+
for (const step of steps) {
|
|
184
|
+
switch (step.kind) {
|
|
185
|
+
case 'create_model': {
|
|
186
|
+
// Reuse the provisioner for the full table (base cols + fields + enum
|
|
187
|
+
// checks + RLS), minus its `CREATE SCHEMA` (the schema already exists
|
|
188
|
+
// mid-migration).
|
|
189
|
+
const def = next.models[step.model];
|
|
190
|
+
if (!def)
|
|
191
|
+
break;
|
|
192
|
+
const sub = { v: next.v, models: { [step.model]: def }, identityRoles: next.identityRoles };
|
|
193
|
+
for (const s of generateProvisionPlan(sub, targetSchema).statements) {
|
|
194
|
+
if (!s.startsWith('CREATE SCHEMA'))
|
|
195
|
+
statements.push(s);
|
|
196
|
+
}
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
case 'drop_model':
|
|
200
|
+
statements.push(`DROP TABLE IF EXISTS ${qtFor(step.tableName)};`);
|
|
201
|
+
break;
|
|
202
|
+
case 'rename_model': {
|
|
203
|
+
const fromTable = tableOfModel(prev, step.from);
|
|
204
|
+
const toTable = tableOfModel(next, step.to);
|
|
205
|
+
// A logical model rename only needs SQL when the physical table name
|
|
206
|
+
// actually changes; if tableName is unchanged the rename is metadata.
|
|
207
|
+
if (fromTable && toTable && fromTable !== toTable) {
|
|
208
|
+
statements.push(`ALTER TABLE ${qtFor(fromTable)} RENAME TO ${q(toTable)};`);
|
|
209
|
+
}
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
case 'add_field': {
|
|
213
|
+
const table = tableOfModel(next, step.model);
|
|
214
|
+
if (!table)
|
|
215
|
+
break;
|
|
216
|
+
const qt = qtFor(table);
|
|
217
|
+
const col = columnNameOf(step.field, step.meta);
|
|
218
|
+
// Added nullable first (the column is born NULL on every existing row).
|
|
219
|
+
statements.push(`ALTER TABLE ${qt} ADD COLUMN IF NOT EXISTS ${q(col)} ${sqlType(step.meta.type)};`);
|
|
220
|
+
if (step.meta.type === 'enum' && step.meta.enumValues?.length) {
|
|
221
|
+
statements.push(...enumCheckStatements(table, col, qt, step.meta.enumValues));
|
|
222
|
+
}
|
|
223
|
+
// Backfill + enforce NOT NULL only with a supplied seed value. Without
|
|
224
|
+
// one, a required field stays nullable (gated `unexecutable` upstream).
|
|
225
|
+
const addBf = backfillFor(step.model, step.field);
|
|
226
|
+
if (addBf !== undefined) {
|
|
227
|
+
statements.push(`UPDATE ${qt} SET ${q(col)} = ${sqlLiteral(addBf.value, step.meta.type)} WHERE ${q(col)} IS NULL;`);
|
|
228
|
+
if (!step.meta.isOptional) {
|
|
229
|
+
statements.push(`ALTER TABLE ${qt} ALTER COLUMN ${q(col)} SET NOT NULL;`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (step.meta.isIndexed) {
|
|
233
|
+
statements.push(`CREATE INDEX IF NOT EXISTS ${q(indexName(table, col))} ON ${qt} (${q(col)});`);
|
|
234
|
+
}
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
case 'drop_field': {
|
|
238
|
+
const table = tableOfModel(next, step.model);
|
|
239
|
+
if (!table)
|
|
240
|
+
break;
|
|
241
|
+
const prevMeta = prev?.models[step.model]?.fields[step.field];
|
|
242
|
+
statements.push(`ALTER TABLE ${qtFor(table)} DROP COLUMN IF EXISTS ${q(columnNameOf(step.field, prevMeta))};`);
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
case 'rename_field': {
|
|
246
|
+
const table = tableOfModel(next, step.model);
|
|
247
|
+
if (!table)
|
|
248
|
+
break;
|
|
249
|
+
const prevMeta = prev?.models[step.model]?.fields[step.from];
|
|
250
|
+
const nextMeta = next.models[step.model]?.fields[step.to];
|
|
251
|
+
const fromCol = columnNameOf(step.from, prevMeta);
|
|
252
|
+
const toCol = columnNameOf(step.to, nextMeta);
|
|
253
|
+
if (fromCol === toCol)
|
|
254
|
+
break;
|
|
255
|
+
statements.push(`ALTER TABLE ${qtFor(table)} RENAME COLUMN ${q(fromCol)} TO ${q(toCol)};`);
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
case 'alter_field': {
|
|
259
|
+
const table = tableOfModel(next, step.model);
|
|
260
|
+
if (!table)
|
|
261
|
+
break;
|
|
262
|
+
const qt = qtFor(table);
|
|
263
|
+
const nextMeta = next.models[step.model]?.fields[step.field];
|
|
264
|
+
let col = columnNameOf(step.field, nextMeta);
|
|
265
|
+
const ch = step.changes;
|
|
266
|
+
// 0. Physical column rename. Subsequent alterations must address
|
|
267
|
+
// the new name.
|
|
268
|
+
if (ch.column) {
|
|
269
|
+
statements.push(`ALTER TABLE ${qt} RENAME COLUMN ${q(ch.column.from)} TO ${q(ch.column.to)};`);
|
|
270
|
+
col = ch.column.to;
|
|
271
|
+
}
|
|
272
|
+
// 1. Type — in-place cast or lossy drop-and-recreate.
|
|
273
|
+
if (ch.type) {
|
|
274
|
+
const target = sqlType(ch.type.to);
|
|
275
|
+
if (ch.type.cast === 'notCastable') {
|
|
276
|
+
statements.push(`ALTER TABLE ${qt} DROP COLUMN IF EXISTS ${q(col)};`);
|
|
277
|
+
statements.push(`ALTER TABLE ${qt} ADD COLUMN IF NOT EXISTS ${q(col)} ${target};`);
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
statements.push(`ALTER TABLE ${qt} ALTER COLUMN ${q(col)} TYPE ${target} USING ${q(col)}::${target};`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// 2. Enum CHECK — drop when leaving enum; (re)build when arriving at or
|
|
284
|
+
// re-valuing an enum. Reads the full target value set from `next`.
|
|
285
|
+
if (ch.type?.from === 'enum' && nextMeta?.type !== 'enum') {
|
|
286
|
+
statements.push(`ALTER TABLE ${qt} DROP CONSTRAINT IF EXISTS ${q(`${table}_${col}_enum`)};`);
|
|
287
|
+
}
|
|
288
|
+
else if (nextMeta?.type === 'enum' && (ch.enumValues || ch.type)) {
|
|
289
|
+
statements.push(...enumCheckStatements(table, col, qt, nextMeta.enumValues ?? []));
|
|
290
|
+
}
|
|
291
|
+
// 3. Nullability. DROP NOT NULL is always safe. SET NOT NULL is gated
|
|
292
|
+
// upstream (unexecutable on a table with NULLs); a supplied backfill
|
|
293
|
+
// seeds the existing NULLs first so the constraint can take.
|
|
294
|
+
if (ch.nullability) {
|
|
295
|
+
if (ch.nullability.toOptional) {
|
|
296
|
+
statements.push(`ALTER TABLE ${qt} ALTER COLUMN ${q(col)} DROP NOT NULL;`);
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
const bf = backfillFor(step.model, step.field);
|
|
300
|
+
if (bf !== undefined && nextMeta) {
|
|
301
|
+
statements.push(`UPDATE ${qt} SET ${q(col)} = ${sqlLiteral(bf.value, nextMeta.type)} WHERE ${q(col)} IS NULL;`);
|
|
302
|
+
}
|
|
303
|
+
statements.push(`ALTER TABLE ${qt} ALTER COLUMN ${q(col)} SET NOT NULL;`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// 4. Index.
|
|
307
|
+
if (ch.indexed) {
|
|
308
|
+
statements.push(ch.indexed.to
|
|
309
|
+
? `CREATE INDEX IF NOT EXISTS ${q(indexName(table, col))} ON ${qt} (${q(col)});`
|
|
310
|
+
: `DROP INDEX IF EXISTS ${qs}.${q(indexName(table, col))};`);
|
|
311
|
+
}
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return { appSchema: targetSchema, statements };
|
|
317
|
+
}
|