@abloatai/ablo 0.5.1 → 0.6.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 +16 -0
- package/README.md +217 -122
- package/dist/BaseSyncedStore.d.ts +2 -2
- package/dist/BaseSyncedStore.js +2 -2
- package/dist/api/index.d.ts +3 -3
- package/dist/api/index.js +1 -1
- package/dist/client/Ablo.d.ts +90 -93
- package/dist/client/Ablo.js +121 -60
- package/dist/client/ApiClient.d.ts +14 -14
- package/dist/client/ApiClient.js +81 -55
- package/dist/client/createInternalComponents.d.ts +2 -3
- package/dist/client/createInternalComponents.js +2 -3
- package/dist/client/createModelProxy.d.ts +90 -87
- package/dist/client/createModelProxy.js +124 -127
- package/dist/client/index.d.ts +6 -7
- package/dist/client/index.js +4 -5
- package/dist/client/validateAbloOptions.js +3 -3
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +7 -0
- package/dist/errors.d.ts +8 -8
- package/dist/errors.js +18 -10
- package/dist/index.d.ts +9 -8
- package/dist/index.js +7 -11
- package/dist/interfaces/index.d.ts +2 -10
- package/dist/mutators/Transaction.d.ts +2 -2
- package/dist/mutators/Transaction.js +2 -2
- package/dist/mutators/mutateActions.d.ts +44 -0
- package/dist/{react/useMutate.js → mutators/mutateActions.js} +11 -28
- package/dist/mutators/readerActions.d.ts +32 -0
- package/dist/{react/useReader.js → mutators/readerActions.js} +2 -18
- package/dist/query/types.d.ts +1 -1
- package/dist/react/AbloProvider.d.ts +1 -1
- package/dist/react/AbloProvider.js +3 -3
- package/dist/react/context.d.ts +4 -4
- package/dist/react/index.d.ts +4 -5
- package/dist/react/index.js +3 -7
- package/dist/react/useAblo.d.ts +14 -14
- package/dist/react/useAblo.js +26 -26
- package/dist/react/useIntent.d.ts +2 -2
- package/dist/react/useIntent.js +2 -2
- package/dist/react/useMutators.d.ts +1 -1
- package/dist/react/usePresence.d.ts +3 -3
- package/dist/react/usePresence.js +4 -4
- package/dist/react/useUndoScope.d.ts +1 -1
- package/dist/schema/diff.d.ts +161 -0
- package/dist/schema/diff.js +262 -0
- package/dist/schema/generate.d.ts +19 -0
- package/dist/schema/generate.js +87 -0
- package/dist/schema/index.d.ts +4 -1
- package/dist/schema/index.js +7 -1
- package/dist/schema/schema.d.ts +83 -32
- package/dist/schema/schema.js +58 -12
- package/dist/schema/serialize.d.ts +92 -0
- package/dist/schema/serialize.js +227 -0
- package/dist/sync/SyncWebSocket.d.ts +17 -0
- package/dist/sync/SyncWebSocket.js +46 -1
- package/dist/sync/awaitIntentGrant.d.ts +26 -0
- package/dist/sync/awaitIntentGrant.js +60 -0
- package/dist/sync/createIntentStream.js +43 -4
- package/dist/sync/createPresenceStream.js +1 -1
- package/dist/sync/participants.d.ts +2 -2
- package/dist/sync/participants.js +4 -4
- package/dist/types/global.d.ts +43 -52
- package/dist/types/global.js +16 -18
- package/dist/types/streams.d.ts +37 -9
- package/docs/api.md +68 -158
- package/docs/audit.md +5 -5
- package/docs/client-behavior.md +41 -42
- package/docs/coordination.md +294 -0
- package/docs/data-sources.md +14 -14
- package/docs/examples/agent-human.md +30 -32
- package/docs/examples/ai-sdk-tool.md +32 -33
- package/docs/examples/existing-python-backend.md +35 -33
- package/docs/examples/nextjs.md +24 -25
- package/docs/examples/server-agent.md +20 -61
- package/docs/guarantees.md +30 -55
- package/docs/identity.md +458 -0
- package/docs/index.md +12 -24
- package/docs/integration-guide.md +106 -116
- package/docs/interaction-model.md +29 -95
- package/docs/mcp/claude-code.md +3 -3
- package/docs/mcp/cursor.md +1 -1
- package/docs/mcp/windsurf.md +1 -1
- package/docs/mcp.md +11 -26
- package/docs/quickstart.md +43 -49
- package/docs/react.md +73 -23
- package/docs/roadmap.md +5 -7
- package/llms.txt +34 -39
- package/package.json +1 -1
- package/dist/react/useMutate.d.ts +0 -83
- package/dist/react/useQuery.d.ts +0 -123
- package/dist/react/useQuery.js +0 -145
- package/dist/react/useReader.d.ts +0 -69
- package/docs/capabilities.md +0 -163
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema diff + migration planning — the pure core of the managed-migration loop.
|
|
3
|
+
*
|
|
4
|
+
* Given two serialized schemas (the active one and the one being pushed), produce
|
|
5
|
+
* an ordered list of {@link MigrationStep}s describing how to evolve the database,
|
|
6
|
+
* and a {@link MigrationClassification} splitting the risky parts into *warnings*
|
|
7
|
+
* (execute but may lose/risk data) and *unexecutable* steps (fail on a non-empty
|
|
8
|
+
* table without a backfill/default). SQL emission and execution live elsewhere
|
|
9
|
+
* (server-side, where the type map + RLS live); this module is intentionally pure
|
|
10
|
+
* and DB-free so it is exhaustively unit-testable and reusable by the CLI.
|
|
11
|
+
*
|
|
12
|
+
* Design borrowed from mature tools:
|
|
13
|
+
* - **Drizzle Kit**: keep the differ pure and inject RENAME decisions as data
|
|
14
|
+
* (the {@link RenameHints} resolver seam) rather than guessing — the same
|
|
15
|
+
* engine is then headless-testable and drivable by an interactive prompt.
|
|
16
|
+
* - **Prisma migration engine**: a two-tier destructive classification
|
|
17
|
+
* (warning vs unexecutable) and a type-change sub-tier
|
|
18
|
+
* (safe / risky / not-castable) that decides in-place `ALTER TYPE` vs a
|
|
19
|
+
* lossy drop-and-recreate.
|
|
20
|
+
* - **Atlas**: a single `alter_field` step carrying *which* facets changed
|
|
21
|
+
* (type / nullability / enum / index) instead of N discrete alter steps.
|
|
22
|
+
*
|
|
23
|
+
* Step ordering is the expand→contract sequence (add before drop, widen before
|
|
24
|
+
* narrow): create models → rename → add columns (always nullable) → alter →
|
|
25
|
+
* drop columns → drop models. NOT NULL is never set on add — it is an
|
|
26
|
+
* `alter_field` nullability change that a backfill must precede.
|
|
27
|
+
*/
|
|
28
|
+
import type { FieldMeta } from './field.js';
|
|
29
|
+
import type { SchemaJSON } from './serialize.js';
|
|
30
|
+
export type FieldType = FieldMeta['type'];
|
|
31
|
+
/** Whether a Postgres `ALTER COLUMN … TYPE` can preserve the existing data. */
|
|
32
|
+
export type CastSafety = 'safe' | 'risky' | 'notCastable';
|
|
33
|
+
export interface FieldTypeChange {
|
|
34
|
+
readonly from: FieldType;
|
|
35
|
+
readonly to: FieldType;
|
|
36
|
+
/** `safe` → plain ALTER TYPE; `risky` → ALTER w/ USING (may fail per-row);
|
|
37
|
+
* `notCastable` → drop-and-recreate (data loss). */
|
|
38
|
+
readonly cast: CastSafety;
|
|
39
|
+
}
|
|
40
|
+
/** `isOptional` transition. `true → false` is the dangerous direction. */
|
|
41
|
+
export interface NullabilityChange {
|
|
42
|
+
readonly fromOptional: boolean;
|
|
43
|
+
readonly toOptional: boolean;
|
|
44
|
+
}
|
|
45
|
+
export interface EnumValuesChange {
|
|
46
|
+
readonly added: readonly string[];
|
|
47
|
+
readonly removed: readonly string[];
|
|
48
|
+
}
|
|
49
|
+
export interface IndexChange {
|
|
50
|
+
readonly from: boolean;
|
|
51
|
+
readonly to: boolean;
|
|
52
|
+
}
|
|
53
|
+
/** The facets of a single column that changed (Atlas-style bitmask, as data). */
|
|
54
|
+
export interface FieldChanges {
|
|
55
|
+
readonly type?: FieldTypeChange;
|
|
56
|
+
readonly nullability?: NullabilityChange;
|
|
57
|
+
readonly enumValues?: EnumValuesChange;
|
|
58
|
+
readonly indexed?: IndexChange;
|
|
59
|
+
}
|
|
60
|
+
export type MigrationStep = {
|
|
61
|
+
readonly kind: 'create_model';
|
|
62
|
+
readonly model: string;
|
|
63
|
+
readonly tableName: string;
|
|
64
|
+
} | {
|
|
65
|
+
readonly kind: 'drop_model';
|
|
66
|
+
readonly model: string;
|
|
67
|
+
readonly tableName: string;
|
|
68
|
+
} | {
|
|
69
|
+
readonly kind: 'rename_model';
|
|
70
|
+
readonly from: string;
|
|
71
|
+
readonly to: string;
|
|
72
|
+
} | {
|
|
73
|
+
readonly kind: 'add_field';
|
|
74
|
+
readonly model: string;
|
|
75
|
+
readonly field: string;
|
|
76
|
+
readonly meta: FieldMeta;
|
|
77
|
+
} | {
|
|
78
|
+
readonly kind: 'drop_field';
|
|
79
|
+
readonly model: string;
|
|
80
|
+
readonly field: string;
|
|
81
|
+
} | {
|
|
82
|
+
readonly kind: 'rename_field';
|
|
83
|
+
readonly model: string;
|
|
84
|
+
readonly from: string;
|
|
85
|
+
readonly to: string;
|
|
86
|
+
} | {
|
|
87
|
+
readonly kind: 'alter_field';
|
|
88
|
+
readonly model: string;
|
|
89
|
+
readonly field: string;
|
|
90
|
+
readonly changes: FieldChanges;
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* Rename decisions, injected as data (Drizzle's resolver seam). Without a hint,
|
|
94
|
+
* a removed+added pair reads as drop+add (lossy) — the same safe default Prisma
|
|
95
|
+
* takes. `field.model` refers to the model key in the NEXT schema (post any
|
|
96
|
+
* model rename).
|
|
97
|
+
*/
|
|
98
|
+
export interface RenameHints {
|
|
99
|
+
readonly models?: readonly {
|
|
100
|
+
readonly from: string;
|
|
101
|
+
readonly to: string;
|
|
102
|
+
}[];
|
|
103
|
+
readonly fields?: readonly {
|
|
104
|
+
readonly model: string;
|
|
105
|
+
readonly from: string;
|
|
106
|
+
readonly to: string;
|
|
107
|
+
}[];
|
|
108
|
+
}
|
|
109
|
+
export declare function classifyCast(from: FieldType, to: FieldType): CastSafety;
|
|
110
|
+
/**
|
|
111
|
+
* Diff two serialized schemas into an ordered, expand→contract migration plan.
|
|
112
|
+
* `prev` is the active schema (`null` for a first push → all creates). Rename
|
|
113
|
+
* decisions are supplied via {@link RenameHints}; anything not hinted reads as
|
|
114
|
+
* drop+add.
|
|
115
|
+
*/
|
|
116
|
+
export declare function diffSchema(prev: SchemaJSON | null, next: SchemaJSON, hints?: RenameHints): MigrationStep[];
|
|
117
|
+
export type WarningCode = 'drop_model' | 'drop_field' | 'risky_cast' | 'lossy_recreate' | 'enum_value_removed';
|
|
118
|
+
export type BlockerCode = 'required_field_added' | 'made_required';
|
|
119
|
+
export interface MigrationSignal {
|
|
120
|
+
readonly code: WarningCode | BlockerCode;
|
|
121
|
+
readonly model: string;
|
|
122
|
+
readonly field?: string;
|
|
123
|
+
readonly detail: string;
|
|
124
|
+
}
|
|
125
|
+
export interface MigrationClassification {
|
|
126
|
+
/** Execute but may lose or risk data on a non-empty table. */
|
|
127
|
+
readonly warnings: readonly MigrationSignal[];
|
|
128
|
+
/** Will fail on a non-empty table unless a default/backfill is supplied. */
|
|
129
|
+
readonly unexecutable: readonly MigrationSignal[];
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Classify a plan's steps into Prisma-style warnings vs unexecutable. The IR
|
|
133
|
+
* carries no per-field default, so a non-optional `add_field` is conservatively
|
|
134
|
+
* unexecutable (a backfill or default resolves it) — we cannot prove a default
|
|
135
|
+
* exists. Classification is rule-based (schema-derived); the runtime layer can
|
|
136
|
+
* downgrade a signal to a no-op when the target table is empty.
|
|
137
|
+
*/
|
|
138
|
+
export declare function classifyMigration(steps: readonly MigrationStep[]): MigrationClassification;
|
|
139
|
+
/** Convenience: a plan is safe to auto-apply iff it has no unexecutable steps. */
|
|
140
|
+
export declare function isAutoApplicable(classification: MigrationClassification): boolean;
|
|
141
|
+
/**
|
|
142
|
+
* A constant value to seed into existing rows so an otherwise-`unexecutable`
|
|
143
|
+
* step becomes safe: a required field added to a non-empty table, or a field
|
|
144
|
+
* made required while NULLs exist. Deliberately a CONSTANT (not an SQL
|
|
145
|
+
* expression) — arbitrary backfill logic is out of scope; this serves the
|
|
146
|
+
* common "new column defaults to X" case only. `value` is typed to the field.
|
|
147
|
+
*/
|
|
148
|
+
export interface BackfillValue {
|
|
149
|
+
readonly model: string;
|
|
150
|
+
readonly field: string;
|
|
151
|
+
readonly value: string | number | boolean;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Does a provided backfill resolve this blocker? Only the two row-dependent
|
|
155
|
+
* blockers (`required_field_added`, `made_required`) are backfill-resolvable; a
|
|
156
|
+
* data-loss *warning* is not — that always needs `force`.
|
|
157
|
+
*/
|
|
158
|
+
export declare function isBlockerResolved(signal: MigrationSignal, backfills: readonly BackfillValue[]): boolean;
|
|
159
|
+
/** The unexecutable signals NOT covered by a supplied backfill. Empty → the push
|
|
160
|
+
* can proceed (modulo the separate `warnings`/`force` gate). */
|
|
161
|
+
export declare function unresolvedBlockers(classification: MigrationClassification, backfills: readonly BackfillValue[]): readonly MigrationSignal[];
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema diff + migration planning — the pure core of the managed-migration loop.
|
|
3
|
+
*
|
|
4
|
+
* Given two serialized schemas (the active one and the one being pushed), produce
|
|
5
|
+
* an ordered list of {@link MigrationStep}s describing how to evolve the database,
|
|
6
|
+
* and a {@link MigrationClassification} splitting the risky parts into *warnings*
|
|
7
|
+
* (execute but may lose/risk data) and *unexecutable* steps (fail on a non-empty
|
|
8
|
+
* table without a backfill/default). SQL emission and execution live elsewhere
|
|
9
|
+
* (server-side, where the type map + RLS live); this module is intentionally pure
|
|
10
|
+
* and DB-free so it is exhaustively unit-testable and reusable by the CLI.
|
|
11
|
+
*
|
|
12
|
+
* Design borrowed from mature tools:
|
|
13
|
+
* - **Drizzle Kit**: keep the differ pure and inject RENAME decisions as data
|
|
14
|
+
* (the {@link RenameHints} resolver seam) rather than guessing — the same
|
|
15
|
+
* engine is then headless-testable and drivable by an interactive prompt.
|
|
16
|
+
* - **Prisma migration engine**: a two-tier destructive classification
|
|
17
|
+
* (warning vs unexecutable) and a type-change sub-tier
|
|
18
|
+
* (safe / risky / not-castable) that decides in-place `ALTER TYPE` vs a
|
|
19
|
+
* lossy drop-and-recreate.
|
|
20
|
+
* - **Atlas**: a single `alter_field` step carrying *which* facets changed
|
|
21
|
+
* (type / nullability / enum / index) instead of N discrete alter steps.
|
|
22
|
+
*
|
|
23
|
+
* Step ordering is the expand→contract sequence (add before drop, widen before
|
|
24
|
+
* narrow): create models → rename → add columns (always nullable) → alter →
|
|
25
|
+
* drop columns → drop models. NOT NULL is never set on add — it is an
|
|
26
|
+
* `alter_field` nullability change that a backfill must precede.
|
|
27
|
+
*/
|
|
28
|
+
// ── Cast safety matrix ────────────────────────────────────────────────────────
|
|
29
|
+
// Keyed `${from}->${to}` over the 6 sync field types. Targets that map to TEXT
|
|
30
|
+
// (`string`) accept any scalar losslessly; tightening into an `enum` adds a CHECK
|
|
31
|
+
// that existing rows may violate (risky); narrowing into number/bool/date/json
|
|
32
|
+
// is risky (USING cast can fail per-row) or impossible (notCastable).
|
|
33
|
+
const CAST = {
|
|
34
|
+
// → string (TEXT): always safe
|
|
35
|
+
'number->string': 'safe', 'boolean->string': 'safe', 'date->string': 'safe',
|
|
36
|
+
'enum->string': 'safe', 'json->string': 'safe',
|
|
37
|
+
// → enum (TEXT + CHECK): constraint over existing data is risky
|
|
38
|
+
'string->enum': 'risky', 'number->enum': 'risky', 'boolean->enum': 'risky',
|
|
39
|
+
'date->enum': 'risky', 'json->enum': 'notCastable',
|
|
40
|
+
// → number (DOUBLE PRECISION)
|
|
41
|
+
'string->number': 'risky', 'enum->number': 'risky', 'boolean->number': 'notCastable',
|
|
42
|
+
'date->number': 'notCastable', 'json->number': 'notCastable',
|
|
43
|
+
// → boolean
|
|
44
|
+
'string->boolean': 'risky', 'enum->boolean': 'risky', 'number->boolean': 'risky',
|
|
45
|
+
'date->boolean': 'notCastable', 'json->boolean': 'notCastable',
|
|
46
|
+
// → date (TIMESTAMPTZ)
|
|
47
|
+
'string->date': 'risky', 'enum->date': 'risky', 'number->date': 'notCastable',
|
|
48
|
+
'boolean->date': 'notCastable', 'json->date': 'notCastable',
|
|
49
|
+
// → json (JSONB)
|
|
50
|
+
'string->json': 'risky', 'enum->json': 'risky', 'number->json': 'notCastable',
|
|
51
|
+
'boolean->json': 'notCastable', 'date->json': 'notCastable',
|
|
52
|
+
};
|
|
53
|
+
export function classifyCast(from, to) {
|
|
54
|
+
if (from === to)
|
|
55
|
+
return 'safe';
|
|
56
|
+
return CAST[`${from}->${to}`] ?? 'notCastable';
|
|
57
|
+
}
|
|
58
|
+
// ── Diff ──────────────────────────────────────────────────────────────────────
|
|
59
|
+
function diffEnumValues(from, to) {
|
|
60
|
+
const a = new Set(from ?? []);
|
|
61
|
+
const b = new Set(to ?? []);
|
|
62
|
+
const added = [...b].filter((v) => !a.has(v));
|
|
63
|
+
const removed = [...a].filter((v) => !b.has(v));
|
|
64
|
+
if (added.length === 0 && removed.length === 0)
|
|
65
|
+
return undefined;
|
|
66
|
+
return { added, removed };
|
|
67
|
+
}
|
|
68
|
+
function diffField(prev, next) {
|
|
69
|
+
const changes = {};
|
|
70
|
+
if (prev.type !== next.type) {
|
|
71
|
+
changes.type = { from: prev.type, to: next.type, cast: classifyCast(prev.type, next.type) };
|
|
72
|
+
}
|
|
73
|
+
if (prev.isOptional !== next.isOptional) {
|
|
74
|
+
changes.nullability = { fromOptional: prev.isOptional, toOptional: next.isOptional };
|
|
75
|
+
}
|
|
76
|
+
// Enum value drift only matters while the field is (still) an enum; a type
|
|
77
|
+
// change away from enum is already captured by `type`.
|
|
78
|
+
if (prev.type === 'enum' && next.type === 'enum') {
|
|
79
|
+
const ev = diffEnumValues(prev.enumValues, next.enumValues);
|
|
80
|
+
if (ev)
|
|
81
|
+
changes.enumValues = ev;
|
|
82
|
+
}
|
|
83
|
+
if (prev.isIndexed !== next.isIndexed) {
|
|
84
|
+
changes.indexed = { from: prev.isIndexed, to: next.isIndexed };
|
|
85
|
+
}
|
|
86
|
+
return Object.keys(changes).length === 0 ? null : changes;
|
|
87
|
+
}
|
|
88
|
+
function tableNameOf(model, key) {
|
|
89
|
+
return model.tableName ?? key;
|
|
90
|
+
}
|
|
91
|
+
function diffModelFields(model, prev, next, fieldRenames) {
|
|
92
|
+
const steps = [];
|
|
93
|
+
const renameByNewName = new Map(fieldRenames.map((r) => [r.to, r.from]));
|
|
94
|
+
const renamedFromNames = new Set(fieldRenames.map((r) => r.from));
|
|
95
|
+
// Renames first (so subsequent alter steps reference the new name).
|
|
96
|
+
for (const { from, to } of fieldRenames) {
|
|
97
|
+
if (from in prev.fields && to in next.fields) {
|
|
98
|
+
steps.push({ kind: 'rename_field', model, from, to });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Added (present in next, not in prev, and not the target of a rename).
|
|
102
|
+
for (const [name, meta] of Object.entries(next.fields)) {
|
|
103
|
+
if (name in prev.fields)
|
|
104
|
+
continue;
|
|
105
|
+
if (renameByNewName.has(name))
|
|
106
|
+
continue;
|
|
107
|
+
steps.push({ kind: 'add_field', model, field: name, meta });
|
|
108
|
+
}
|
|
109
|
+
// Altered: every field present in both (directly or via rename).
|
|
110
|
+
for (const [name, nextMeta] of Object.entries(next.fields)) {
|
|
111
|
+
const prevName = renameByNewName.get(name) ?? name;
|
|
112
|
+
const prevMeta = prev.fields[prevName];
|
|
113
|
+
if (!prevMeta)
|
|
114
|
+
continue;
|
|
115
|
+
const changes = diffField(prevMeta, nextMeta);
|
|
116
|
+
if (changes)
|
|
117
|
+
steps.push({ kind: 'alter_field', model, field: name, changes });
|
|
118
|
+
}
|
|
119
|
+
// Dropped (present in prev, not in next, and not renamed away).
|
|
120
|
+
for (const name of Object.keys(prev.fields)) {
|
|
121
|
+
if (name in next.fields)
|
|
122
|
+
continue;
|
|
123
|
+
if (renamedFromNames.has(name))
|
|
124
|
+
continue;
|
|
125
|
+
steps.push({ kind: 'drop_field', model, field: name });
|
|
126
|
+
}
|
|
127
|
+
return steps;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Diff two serialized schemas into an ordered, expand→contract migration plan.
|
|
131
|
+
* `prev` is the active schema (`null` for a first push → all creates). Rename
|
|
132
|
+
* decisions are supplied via {@link RenameHints}; anything not hinted reads as
|
|
133
|
+
* drop+add.
|
|
134
|
+
*/
|
|
135
|
+
export function diffSchema(prev, next, hints = {}) {
|
|
136
|
+
if (!prev) {
|
|
137
|
+
// First push: every model is created, with its fields carried in the
|
|
138
|
+
// create (no per-field add steps — the table is born with them).
|
|
139
|
+
return Object.entries(next.models).map(([model, def]) => ({
|
|
140
|
+
kind: 'create_model',
|
|
141
|
+
model,
|
|
142
|
+
tableName: tableNameOf(def, model),
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
const modelRenames = hints.models ?? [];
|
|
146
|
+
const renameByNewModel = new Map(modelRenames.map((r) => [r.to, r.from]));
|
|
147
|
+
const renamedFromModels = new Set(modelRenames.map((r) => r.from));
|
|
148
|
+
const fieldHints = hints.fields ?? [];
|
|
149
|
+
const creates = [];
|
|
150
|
+
const renames = [];
|
|
151
|
+
const fieldSteps = [];
|
|
152
|
+
const drops = [];
|
|
153
|
+
// New + renamed models, and per-model field diffs.
|
|
154
|
+
for (const [model, nextDef] of Object.entries(next.models)) {
|
|
155
|
+
const prevModelKey = renameByNewModel.get(model) ?? model;
|
|
156
|
+
const prevDef = prev.models[prevModelKey];
|
|
157
|
+
if (!prevDef) {
|
|
158
|
+
creates.push({ kind: 'create_model', model, tableName: tableNameOf(nextDef, model) });
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (renameByNewModel.has(model)) {
|
|
162
|
+
renames.push({ kind: 'rename_model', from: prevModelKey, to: model });
|
|
163
|
+
}
|
|
164
|
+
const myFieldRenames = fieldHints
|
|
165
|
+
.filter((f) => f.model === model)
|
|
166
|
+
.map((f) => ({ from: f.from, to: f.to }));
|
|
167
|
+
fieldSteps.push(...diffModelFields(model, prevDef, nextDef, myFieldRenames));
|
|
168
|
+
}
|
|
169
|
+
// Dropped models (in prev, not in next, not renamed away).
|
|
170
|
+
for (const [model, prevDef] of Object.entries(prev.models)) {
|
|
171
|
+
if (model in next.models)
|
|
172
|
+
continue;
|
|
173
|
+
if (renamedFromModels.has(model))
|
|
174
|
+
continue;
|
|
175
|
+
drops.push({ kind: 'drop_model', model, tableName: tableNameOf(prevDef, model) });
|
|
176
|
+
}
|
|
177
|
+
// Expand → contract ordering. Within fieldSteps the per-model helper already
|
|
178
|
+
// emits rename → add → alter → drop_field, which preserves the same invariant.
|
|
179
|
+
return [...creates, ...renames, ...fieldSteps, ...drops];
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Classify a plan's steps into Prisma-style warnings vs unexecutable. The IR
|
|
183
|
+
* carries no per-field default, so a non-optional `add_field` is conservatively
|
|
184
|
+
* unexecutable (a backfill or default resolves it) — we cannot prove a default
|
|
185
|
+
* exists. Classification is rule-based (schema-derived); the runtime layer can
|
|
186
|
+
* downgrade a signal to a no-op when the target table is empty.
|
|
187
|
+
*/
|
|
188
|
+
export function classifyMigration(steps) {
|
|
189
|
+
const warnings = [];
|
|
190
|
+
const unexecutable = [];
|
|
191
|
+
for (const step of steps) {
|
|
192
|
+
switch (step.kind) {
|
|
193
|
+
case 'drop_model':
|
|
194
|
+
warnings.push({ code: 'drop_model', model: step.model, detail: `drops table for "${step.model}" (data loss)` });
|
|
195
|
+
break;
|
|
196
|
+
case 'drop_field':
|
|
197
|
+
warnings.push({ code: 'drop_field', model: step.model, field: step.field, detail: `drops column "${step.field}" (data loss)` });
|
|
198
|
+
break;
|
|
199
|
+
case 'add_field':
|
|
200
|
+
if (!step.meta.isOptional) {
|
|
201
|
+
unexecutable.push({
|
|
202
|
+
code: 'required_field_added',
|
|
203
|
+
model: step.model,
|
|
204
|
+
field: step.field,
|
|
205
|
+
detail: `adds required column "${step.field}" — needs a default or backfill on a non-empty table`,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
break;
|
|
209
|
+
case 'alter_field': {
|
|
210
|
+
const { changes } = step;
|
|
211
|
+
if (changes.nullability && changes.nullability.fromOptional && !changes.nullability.toOptional) {
|
|
212
|
+
unexecutable.push({
|
|
213
|
+
code: 'made_required',
|
|
214
|
+
model: step.model,
|
|
215
|
+
field: step.field,
|
|
216
|
+
detail: `makes "${step.field}" required — fails if existing rows are NULL`,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
if (changes.type) {
|
|
220
|
+
if (changes.type.cast === 'risky') {
|
|
221
|
+
warnings.push({ code: 'risky_cast', model: step.model, field: step.field, detail: `${changes.type.from} → ${changes.type.to} may fail per-row` });
|
|
222
|
+
}
|
|
223
|
+
else if (changes.type.cast === 'notCastable') {
|
|
224
|
+
warnings.push({ code: 'lossy_recreate', model: step.model, field: step.field, detail: `${changes.type.from} → ${changes.type.to} requires drop-and-recreate (data loss)` });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (changes.enumValues && changes.enumValues.removed.length > 0) {
|
|
228
|
+
warnings.push({
|
|
229
|
+
code: 'enum_value_removed',
|
|
230
|
+
model: step.model,
|
|
231
|
+
field: step.field,
|
|
232
|
+
detail: `removes enum value(s) ${changes.enumValues.removed.join(', ')} — rows using them violate the new CHECK`,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
// create_model, rename_model, rename_field, add optional field: non-destructive.
|
|
238
|
+
default:
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return { warnings, unexecutable };
|
|
243
|
+
}
|
|
244
|
+
/** Convenience: a plan is safe to auto-apply iff it has no unexecutable steps. */
|
|
245
|
+
export function isAutoApplicable(classification) {
|
|
246
|
+
return classification.unexecutable.length === 0;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Does a provided backfill resolve this blocker? Only the two row-dependent
|
|
250
|
+
* blockers (`required_field_added`, `made_required`) are backfill-resolvable; a
|
|
251
|
+
* data-loss *warning* is not — that always needs `force`.
|
|
252
|
+
*/
|
|
253
|
+
export function isBlockerResolved(signal, backfills) {
|
|
254
|
+
if (signal.code !== 'required_field_added' && signal.code !== 'made_required')
|
|
255
|
+
return false;
|
|
256
|
+
return backfills.some((b) => b.model === signal.model && b.field === signal.field);
|
|
257
|
+
}
|
|
258
|
+
/** The unexecutable signals NOT covered by a supplied backfill. Empty → the push
|
|
259
|
+
* can proceed (modulo the separate `warnings`/`force` gate). */
|
|
260
|
+
export function unresolvedBlockers(classification, backfills) {
|
|
261
|
+
return classification.unexecutable.filter((s) => !isBlockerResolved(s, backfills));
|
|
262
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema → TypeScript type emission — the `generate` half of the loop (the
|
|
3
|
+
* counterpart to `diff`/migrate). Lowers a serialized schema to a `.ts` file of
|
|
4
|
+
* row interfaces + an `AbloSchema` map, so a consumer's app is typed against the
|
|
5
|
+
* exact schema that the database and sync layer enforce. Pure (returns source
|
|
6
|
+
* text); the CLI writes it to disk.
|
|
7
|
+
*
|
|
8
|
+
* This is the SDK's front door: the developer writes ONE `defineSchema`, pushes
|
|
9
|
+
* it (which migrates the DB), and generates types FROM the same schema — so the
|
|
10
|
+
* types they code against, the rows the DB stores, and the entities sync moves
|
|
11
|
+
* are provably the same thing.
|
|
12
|
+
*
|
|
13
|
+
* v1 emits the row shape (base columns + declared fields, enums as literal
|
|
14
|
+
* unions). Relations are resolved by the runtime SDK's typed accessors and are
|
|
15
|
+
* not expanded here.
|
|
16
|
+
*/
|
|
17
|
+
import type { SchemaJSON } from './serialize.js';
|
|
18
|
+
/** Emit a TypeScript module of row interfaces + the `AbloSchema` map. */
|
|
19
|
+
export declare function generateTypes(schema: SchemaJSON): string;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema → TypeScript type emission — the `generate` half of the loop (the
|
|
3
|
+
* counterpart to `diff`/migrate). Lowers a serialized schema to a `.ts` file of
|
|
4
|
+
* row interfaces + an `AbloSchema` map, so a consumer's app is typed against the
|
|
5
|
+
* exact schema that the database and sync layer enforce. Pure (returns source
|
|
6
|
+
* text); the CLI writes it to disk.
|
|
7
|
+
*
|
|
8
|
+
* This is the SDK's front door: the developer writes ONE `defineSchema`, pushes
|
|
9
|
+
* it (which migrates the DB), and generates types FROM the same schema — so the
|
|
10
|
+
* types they code against, the rows the DB stores, and the entities sync moves
|
|
11
|
+
* are provably the same thing.
|
|
12
|
+
*
|
|
13
|
+
* v1 emits the row shape (base columns + declared fields, enums as literal
|
|
14
|
+
* unions). Relations are resolved by the runtime SDK's typed accessors and are
|
|
15
|
+
* not expanded here.
|
|
16
|
+
*/
|
|
17
|
+
/** The base columns every model carries (mirrors `baseFieldsSchema`). */
|
|
18
|
+
const BASE_FIELDS = ['id', 'createdAt', 'updatedAt', 'organizationId', 'createdBy'];
|
|
19
|
+
function tsType(meta) {
|
|
20
|
+
switch (meta.type) {
|
|
21
|
+
case 'string':
|
|
22
|
+
return 'string';
|
|
23
|
+
case 'number':
|
|
24
|
+
return 'number';
|
|
25
|
+
case 'boolean':
|
|
26
|
+
return 'boolean';
|
|
27
|
+
case 'date':
|
|
28
|
+
// `baseFieldsSchema` uses `z.date()` and the driver hydrates timestamptz
|
|
29
|
+
// to Date — declared date fields match.
|
|
30
|
+
return 'Date';
|
|
31
|
+
case 'json':
|
|
32
|
+
return 'unknown';
|
|
33
|
+
case 'enum':
|
|
34
|
+
return meta.enumValues && meta.enumValues.length > 0
|
|
35
|
+
? meta.enumValues.map((v) => `'${v.replace(/'/g, "\\'")}'`).join(' | ')
|
|
36
|
+
: 'string';
|
|
37
|
+
default:
|
|
38
|
+
return 'unknown';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** A valid PascalCase TS identifier from a model's typename (or its key). */
|
|
42
|
+
function interfaceName(model, key) {
|
|
43
|
+
const raw = model.typename && model.typename.trim() ? model.typename : key;
|
|
44
|
+
const id = raw
|
|
45
|
+
.replace(/[^A-Za-z0-9]+/g, ' ')
|
|
46
|
+
.trim()
|
|
47
|
+
.split(/\s+/)
|
|
48
|
+
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
|
|
49
|
+
.join('');
|
|
50
|
+
return /^[A-Za-z_]/.test(id) ? id : `Model${id}`;
|
|
51
|
+
}
|
|
52
|
+
/** Emit a TypeScript module of row interfaces + the `AbloSchema` map. */
|
|
53
|
+
export function generateTypes(schema) {
|
|
54
|
+
const lines = [
|
|
55
|
+
'// Generated by `ablo generate` — do not edit by hand.',
|
|
56
|
+
'// Re-run `ablo generate` after pushing a schema change.',
|
|
57
|
+
'',
|
|
58
|
+
];
|
|
59
|
+
const nameByKey = new Map();
|
|
60
|
+
for (const [key, model] of Object.entries(schema.models)) {
|
|
61
|
+
nameByKey.set(key, interfaceName(model, key));
|
|
62
|
+
}
|
|
63
|
+
for (const [key, model] of Object.entries(schema.models)) {
|
|
64
|
+
lines.push(`export interface ${nameByKey.get(key)} {`);
|
|
65
|
+
lines.push(' id: string;');
|
|
66
|
+
lines.push(' createdAt: Date;');
|
|
67
|
+
lines.push(' updatedAt: Date;');
|
|
68
|
+
lines.push(' organizationId?: string;');
|
|
69
|
+
lines.push(' createdBy?: string;');
|
|
70
|
+
for (const [fieldName, meta] of Object.entries(model.fields)) {
|
|
71
|
+
// A model that redeclares a base column doesn't double-emit it.
|
|
72
|
+
if (BASE_FIELDS.includes(fieldName))
|
|
73
|
+
continue;
|
|
74
|
+
lines.push(` ${fieldName}${meta.isOptional ? '?' : ''}: ${tsType(meta)};`);
|
|
75
|
+
}
|
|
76
|
+
lines.push('}');
|
|
77
|
+
lines.push('');
|
|
78
|
+
}
|
|
79
|
+
// The model map — parameterize the Ablo client with this for typed access.
|
|
80
|
+
lines.push('export interface AbloSchema {');
|
|
81
|
+
for (const key of Object.keys(schema.models)) {
|
|
82
|
+
lines.push(` ${JSON.stringify(key)}: ${nameByKey.get(key)};`);
|
|
83
|
+
}
|
|
84
|
+
lines.push('}');
|
|
85
|
+
lines.push('');
|
|
86
|
+
return lines.join('\n');
|
|
87
|
+
}
|
package/dist/schema/index.d.ts
CHANGED
|
@@ -25,5 +25,8 @@ export { field, indexed, getFieldMeta, type FieldMeta } from './field.js';
|
|
|
25
25
|
export { relation, type RelationDef, type RelationType } from './relation.js';
|
|
26
26
|
export { model, type ModelDef, type ModelOptions, type LoadStrategy, type PersistOptions, type RelationRecord, } from './model.js';
|
|
27
27
|
export { mutable, readOnly, type SugarOptions } from './sugar.js';
|
|
28
|
-
export { defineSchema, composeIdentitySyncGroups, type Schema, type SchemaRecord, type InferModel, type InferCreate, type InferModelNames, type BaseModelFields, type InsertValue, type UpsertValue, type UpdateValue, type DeleteId, type DefineSchemaOptions, type Casing, type CasingConvention, type CasingFn, type IdentityRole, type IdentityContext, } from './schema.js';
|
|
28
|
+
export { defineSchema, composeIdentitySyncGroups, type Schema, type SchemaRecord, type InferModel, type InferCreate, type InferModelNames, type BaseModelFields, type InsertValue, type UpsertValue, type UpdateValue, type DeleteId, type DefineSchemaOptions, type Casing, type CasingConvention, type CasingFn, type IdentityRole, type IdentityContext, identityRole, extractIdentityIds, type IdentityRoleSource, } from './schema.js';
|
|
29
|
+
export { serializeSchema, parseSchema, toSchemaJSON, fromSchemaJSON, schemaHash, type SchemaJSON, type ModelJSON, type RelationJSON, } from './serialize.js';
|
|
30
|
+
export { diffSchema, classifyMigration, classifyCast, isAutoApplicable, isBlockerResolved, unresolvedBlockers, type BackfillValue, type MigrationStep, type FieldChanges, type FieldTypeChange, type NullabilityChange, type EnumValuesChange, type IndexChange, type CastSafety, type FieldType, type RenameHints, type MigrationSignal, type MigrationClassification, type WarningCode, type BlockerCode, } from './diff.js';
|
|
31
|
+
export { generateTypes } from './generate.js';
|
|
29
32
|
export { query, defineQueries, type QueryDef, type QueryRecord, type Queries, type InferQueryInput, type InferQueryResult, } from './queries.js';
|
package/dist/schema/index.js
CHANGED
|
@@ -33,6 +33,12 @@ export { model, } from './model.js';
|
|
|
33
33
|
// falls back to sensible defaults. See sugar.ts for the full pattern.
|
|
34
34
|
export { mutable, readOnly } from './sugar.js';
|
|
35
35
|
// Schema definition + type inference
|
|
36
|
-
export { defineSchema, composeIdentitySyncGroups, } from './schema.js';
|
|
36
|
+
export { defineSchema, composeIdentitySyncGroups, identityRole, extractIdentityIds, } from './schema.js';
|
|
37
|
+
// Schema ⇄ JSON (control-plane transport for hosted multi-tenant)
|
|
38
|
+
export { serializeSchema, parseSchema, toSchemaJSON, fromSchemaJSON, schemaHash, } from './serialize.js';
|
|
39
|
+
// Schema diff + migration planning (pure; SQL emission is server-side)
|
|
40
|
+
export { diffSchema, classifyMigration, classifyCast, isAutoApplicable, isBlockerResolved, unresolvedBlockers, } from './diff.js';
|
|
41
|
+
// Schema → TypeScript type emission (the `generate` half; pure)
|
|
42
|
+
export { generateTypes } from './generate.js';
|
|
37
43
|
// Query definition DSL + type inference
|
|
38
44
|
export { query, defineQueries, } from './queries.js';
|