@clivly/core 0.9.7 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +17 -10
  2. package/dist/builder.cjs +599 -0
  3. package/dist/builder.d.cts +336 -0
  4. package/dist/builder.d.mts +336 -0
  5. package/dist/builder.mjs +589 -0
  6. package/dist/drizzle.d.cts +1 -1
  7. package/dist/drizzle.d.mts +1 -1
  8. package/dist/{entity-config-25v_udeY.cjs → entity-config-C5jBmG2C.cjs} +83 -68
  9. package/dist/{entity-config-buOIVtMM.d.cts → entity-config-DCa785OE.d.cts} +27 -1
  10. package/dist/{entity-config-buOIVtMM.d.mts → entity-config-DCa785OE.d.mts} +27 -1
  11. package/dist/{entity-config-DwGXbUo_.mjs → entity-config-DpHPMFIS.mjs} +71 -38
  12. package/dist/entity-config.cjs +1 -1
  13. package/dist/entity-config.d.cts +1 -1
  14. package/dist/entity-config.d.mts +1 -1
  15. package/dist/entity-config.mjs +1 -1
  16. package/dist/entity-heuristics.cjs +34 -1
  17. package/dist/entity-heuristics.d.cts +8 -2
  18. package/dist/entity-heuristics.d.mts +8 -2
  19. package/dist/entity-heuristics.mjs +34 -2
  20. package/dist/index.cjs +136 -199
  21. package/dist/index.d.cts +135 -89
  22. package/dist/index.d.mts +135 -89
  23. package/dist/index.mjs +124 -188
  24. package/dist/mapping-form.d.cts +1 -1
  25. package/dist/mapping-form.d.mts +1 -1
  26. package/dist/mappings-config.cjs +2 -16
  27. package/dist/mappings-config.d.cts +1 -1
  28. package/dist/mappings-config.d.mts +1 -1
  29. package/dist/mappings-config.mjs +1 -15
  30. package/dist/{projection-OJYCYmS_.cjs → projection-DFgCoyZl.cjs} +0 -6
  31. package/dist/{projection-BXw_iiw5.mjs → projection-SCNrLJMV.mjs} +1 -1
  32. package/dist/schema-meta-Ac7938mT.mjs +46 -0
  33. package/dist/schema-meta-Cgi0MnHh.cjs +69 -0
  34. package/dist/sync-engine.cjs +1 -1
  35. package/dist/sync-engine.d.cts +1 -1
  36. package/dist/sync-engine.d.mts +1 -1
  37. package/dist/sync-engine.mjs +1 -1
  38. package/dist/types.cjs +11 -0
  39. package/dist/types.d.cts +28 -2
  40. package/dist/types.d.mts +28 -2
  41. package/dist/types.mjs +9 -1
  42. package/dist/{view-compiler-u3GGRgnv.mjs → view-compiler-CLvZ0wJW.mjs} +1 -1
  43. package/dist/{view-compiler-D30HNRnA.cjs → view-compiler-DP4Fbj7-.cjs} +1 -1
  44. package/dist/view-compiler.cjs +1 -1
  45. package/dist/view-compiler.d.cts +1 -1
  46. package/dist/view-compiler.d.mts +1 -1
  47. package/dist/view-compiler.mjs +1 -1
  48. package/package.json +11 -1
package/README.md CHANGED
@@ -195,19 +195,26 @@ collide).
195
195
 
196
196
  ### Building one interactively
197
197
 
198
- `ProjectionDraft` and its helpers exist so a builder UI stays presentational —
199
- every rule is a pure function of draft + schema:
198
+ The builder engine lives on the **`@clivly/core/builder`** subpath, deliberately
199
+ kept off the root barrel so the published `clivly` package does not pull it into
200
+ the CLI's declaration emit.
201
+
202
+ It exists so a builder UI stays *presentational*: the UI renders and collects,
203
+ and every structural judgement — what can start a list, what is reachable from
204
+ there, what a change costs — is a pure function of schema + picks answered here.
205
+ A component that walks the graph or classifies cardinality itself is a bug.
200
206
 
201
207
  | Helper | Purpose |
202
208
  | --- | --- |
203
- | `emptyProjectionDraft(baseTable)` | A blank draft |
204
- | `addDraftJoin(draft, joinTable, schema)` | Add a join, keyed uniquely and pre-filled from foreign-key metadata |
205
- | `removeDraftJoin(draft, joinKey)` | Remove a join **and** every binding and identity that addressed it — left behind they fail validation as "unknown ref table", blaming the field for the join's removal |
206
- | `draftRefOptions(draft, schema)` | The tables and columns a field or identity may currently read from, in ref order |
207
- | `suggestJoinConditions(base, joinTable, schema, key)` | Pre-fill a join's `ON` from foreign-key metadata |
208
- | `draftToProjection(draft)` | `ProjectionMapping`, or `null` while incomplete |
209
- | `draftErrors(draft, schema)` | Validation errors, one per cause, each anchored to the section that owns it (`projection.entityKey`, `projection.joins`, …) |
210
- | `describeCardinality(draft, schema)` | Per-join `{ joinKey, fansOut }` for inline hints. Answered from the joins alone, so the warning arrives while the identity is still being chosen |
209
+ | `listStartingRecordOptions(schema)` | What can be the "one row per…" of a list, each offerable or not with a machine-readable reason (no primary key, composite key, thin discovery metadata) |
210
+ | `buildRelationshipGraph(schema)` / `edgesFrom(graph, table)` | A **bidirectional** graph: a table's own foreign keys (child → parent) *and* every other table's keys pointing at it (parent → child) |
211
+ | `enumeratePaths(graph, startTable, options?)` | Ranked routes outward from a starting record, each flagged `isCollection` when it crosses a to-many edge. Budgeted by `MAX_HOPS` / `MAX_CANDIDATES` / `MAX_RESULTS`, and it reports *why* it truncated |
212
+ | `compilePaths({schema, startTable, pickedFields, entityKey})` | Picked fields → a validated `ProjectionMapping`, allocating join keys and bindings. Throws `PathCompileError` for a shape it cannot express |
213
+ | `classifyStartingRecordChange({schema, from, to, picks})` | Which picks survive changing the starting record, which are lost, and how many connections fall away. Survivors come back **repointed** to routes from the new starting record |
214
+
215
+ Reachability is *directional*, which is why that last one cannot be approximated
216
+ by intersecting path keys: a field reachable as a single value from one starting
217
+ record can be a collection from another, and a collection is not pickable at all.
211
218
 
212
219
  `compileProjectionPreview(projection, limit)` compiles the same object to a
213
220
  capped, read-only `SELECT` — used by both sides of the dashboard's preview, so
@@ -0,0 +1,599 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_projection = require("./projection-DFgCoyZl.cjs");
3
+ const require_schema_meta = require("./schema-meta-Cgi0MnHh.cjs");
4
+ //#region src/path-compiler.ts
5
+ /**
6
+ * Compiles picked paths into a `ProjectionMapping` whose joins are in
7
+ * dependency order.
8
+ *
9
+ * This is the last step of the derived-object builder: the user has browsed
10
+ * outward from a starting list and picked fields, and this turns those picks
11
+ * into the existing projection contract — unchanged, so validation, view
12
+ * compilation, paging and sync all consume it as they already do.
13
+ *
14
+ * Four properties carry the weight:
15
+ *
16
+ * 1. **Joins dedupe by edge path, not by table.** Ten fields reached through
17
+ * the same chain of relationships produce one join. Two chains that land on
18
+ * the same table through *different* foreign keys stay two joins, because
19
+ * they are two genuinely different relationships. The ordered edge path is
20
+ * the join's identity.
21
+ *
22
+ * 2. **Dependency order.** Joins are emitted shortest-prefix-first, so a
23
+ * join's ON condition only ever references the base table or a join already
24
+ * declared above it. `validateProjection`'s forward-reference check is the
25
+ * external proof of this, and SQL requires it.
26
+ *
27
+ * 3. **No raw table name is ever an alias.** Every join key is derived from
28
+ * the full edge path — including which FK column each hop followed — and
29
+ * uniquified deterministically. A table-derived alias would silently
30
+ * collapse two different relationships into one.
31
+ *
32
+ * 4. **Collections never compile.** A path that crosses a to-many edge
33
+ * resolves to many rows, not a value. Emitting one as a join would fan the
34
+ * output out and quietly duplicate or collapse mirror rows at sync time.
35
+ * Asked to compile one, this throws rather than emitting something
36
+ * plausible.
37
+ *
38
+ * No table, column, or relationship is ever named in this file — the only
39
+ * inputs are the schema, the starting table, and the user's picks.
40
+ */
41
+ /** The reserved binding name; the view always projects identity as `id`. */
42
+ const RESERVED_FIELD = "id";
43
+ /**
44
+ * Thrown when picks cannot compile to a valid projection.
45
+ *
46
+ * A distinct class so a builder UI can tell "this cannot be expressed" apart
47
+ * from a genuine bug, and so callers can catch it narrowly. Failing loudly is
48
+ * the point: the alternative — emitting a plausible-looking mapping — is the
49
+ * defect class this whole body of work exists to close.
50
+ */
51
+ var PathCompileError = class extends Error {
52
+ constructor(message) {
53
+ super(message);
54
+ this.name = "PathCompileError";
55
+ }
56
+ };
57
+ /**
58
+ * Reduce an arbitrary identifier to the projection key charset.
59
+ *
60
+ * Table and column names come from the host's schema and are not constrained
61
+ * to `PROJECTION_KEY_PATTERN` — they may carry capitals, spaces, or leading
62
+ * digits. Sanitizing is lossy on purpose (two different names can reduce to
63
+ * the same token), which is exactly why the caller disambiguates afterwards
64
+ * rather than trusting this to be injective.
65
+ */
66
+ function sanitize(part) {
67
+ return part.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
68
+ }
69
+ /**
70
+ * A key candidate for the join reached by `edges`.
71
+ *
72
+ * Every hop contributes both the FK column it followed and the table it landed
73
+ * on. Carrying the column is what keeps two FKs into the same table apart:
74
+ * without it, both hops would reduce to the destination table's name and the
75
+ * two relationships would collide into one alias.
76
+ */
77
+ function keyCandidate(edges) {
78
+ const joined = edges.flatMap((edge) => [sanitize(edge.fromColumn), sanitize(edge.toTable)]).filter(Boolean).join("_");
79
+ return require_projection.PROJECTION_KEY_PATTERN.test(joined) ? joined : `j_${joined}`;
80
+ }
81
+ /** The ordered prefixes of a path, longest last. Each prefix is one join. */
82
+ function prefixesOf(edges) {
83
+ return edges.map((_, index) => edges.slice(0, index + 1));
84
+ }
85
+ /**
86
+ * Identity of a join: the full ordered edge path, including direction and both
87
+ * column halves of every hop.
88
+ *
89
+ * This is the deduplication key and it is exact — unlike the *display* key,
90
+ * nothing here is sanitized or lossy, so two distinct relationships can never
91
+ * be conflated into one join.
92
+ */
93
+ function prefixIdentity(edges) {
94
+ return edges.map((e) => `${e.direction}:${e.fromTable}.${e.fromColumn}->${e.toTable}.${e.toColumn}`).join("|");
95
+ }
96
+ /** Assigns each distinct edge path a unique, deterministic key. */
97
+ var JoinKeyAllocator = class {
98
+ byIdentity = /* @__PURE__ */ new Map();
99
+ taken = /* @__PURE__ */ new Set([require_projection.BASE_REF]);
100
+ /**
101
+ * The key for this edge path, allocating one on first sight.
102
+ *
103
+ * Collisions are resolved by a counter rather than by anything derived from
104
+ * call order beyond first-sight order, which is itself deterministic
105
+ * because joins are visited in a fixed sorted order. The same picks
106
+ * therefore always produce the same keys.
107
+ */
108
+ keyFor(edges) {
109
+ const identity = prefixIdentity(edges);
110
+ const existing = this.byIdentity.get(identity);
111
+ if (existing) return existing;
112
+ const candidate = keyCandidate(edges);
113
+ let key = candidate;
114
+ let suffix = 2;
115
+ while (this.taken.has(key)) {
116
+ key = `${candidate}_${suffix}`;
117
+ suffix += 1;
118
+ }
119
+ this.taken.add(key);
120
+ this.byIdentity.set(identity, key);
121
+ return key;
122
+ }
123
+ };
124
+ /** The ref an edge path resolves to: `base` for the root, else a join key. */
125
+ function refTableFor(edges, allocator) {
126
+ return edges.length === 0 ? require_projection.BASE_REF : allocator.keyFor(edges);
127
+ }
128
+ function buildJoin(edges, allocator) {
129
+ const edge = edges.at(-1);
130
+ if (!edge) throw new PathCompileError("cannot build a join from an empty path");
131
+ const parentRef = refTableFor(edges.slice(0, -1), allocator);
132
+ const key = allocator.keyFor(edges);
133
+ const left = {
134
+ table: parentRef,
135
+ column: edge.fromColumn
136
+ };
137
+ const right = {
138
+ table: key,
139
+ column: edge.toColumn
140
+ };
141
+ return {
142
+ key,
143
+ table: edge.toTable,
144
+ type: "left",
145
+ on: [{
146
+ left,
147
+ op: "eq",
148
+ right
149
+ }]
150
+ };
151
+ }
152
+ function assertCompilable(field, startTable) {
153
+ const path = field.path;
154
+ if (!path || path.edges.length === 0) return;
155
+ if (path.isCollection) throw new PathCompileError(`field "${field.name}" is reached through a collection (a to-one relationship is required); a collection is many rows, not a value, so it must be started as its own list rather than compiled into a join`);
156
+ const first = path.edges[0];
157
+ if (first && first.fromTable !== startTable) throw new PathCompileError(`field "${field.name}" follows a path starting at "${first.fromTable}", not the starting table "${startTable}"`);
158
+ }
159
+ function validatePicks(options) {
160
+ const { pickedFields, startTable } = options;
161
+ if (pickedFields.length === 0) throw new PathCompileError("at least one field must be picked");
162
+ const seen = /* @__PURE__ */ new Set();
163
+ for (const field of pickedFields) {
164
+ if (field.name === RESERVED_FIELD) throw new PathCompileError(`"${RESERVED_FIELD}" is a reserved field name — the view always projects identity as "${RESERVED_FIELD}"`);
165
+ if (seen.has(field.name)) throw new PathCompileError(`duplicate field name "${field.name}"`);
166
+ seen.add(field.name);
167
+ assertCompilable(field, startTable);
168
+ }
169
+ }
170
+ /**
171
+ * The starting table's single-column primary key.
172
+ *
173
+ * Fails closed on absent or composite keys rather than guessing a column: an
174
+ * identity that is not provably unique per row is the failure mode that
175
+ * silently duplicates or collapses mirror rows at sync time.
176
+ */
177
+ function basePrimaryKey(schema, startTable) {
178
+ const table = schema.find((t) => t.name === startTable);
179
+ if (!table) throw new PathCompileError(`starting table "${startTable}" is not in the discovered schema`);
180
+ const pk = require_schema_meta.primaryKeyColumns(table);
181
+ const [column] = pk;
182
+ if (!column) throw new PathCompileError(`"${startTable}" has no discovered single-column primary key, which a derived object needs to identify its rows`);
183
+ if (pk.length > 1) throw new PathCompileError(`"${startTable}" has a composite primary key (${pk.join(", ")}), which cannot be used as a derived object's identity`);
184
+ return column;
185
+ }
186
+ /**
187
+ * Every distinct edge-path prefix used by at least one picked field, ordered
188
+ * shortest first.
189
+ *
190
+ * Ordering by depth is what puts joins in dependency order: a prefix of length
191
+ * n can only reference the base table or its own length n-1 prefix, both of
192
+ * which sort strictly earlier. Ties within a depth break on the exact path
193
+ * identity, so the order is total and stable across runs.
194
+ */
195
+ function requiredPrefixes(pickedFields) {
196
+ const byIdentity = /* @__PURE__ */ new Map();
197
+ for (const field of pickedFields) for (const prefix of prefixesOf(field.path?.edges ?? [])) {
198
+ const identity = prefixIdentity(prefix);
199
+ if (!byIdentity.has(identity)) byIdentity.set(identity, prefix);
200
+ }
201
+ return [...byIdentity.entries()].sort(([leftId, left], [rightId, right]) => {
202
+ const byDepth = left.length - right.length;
203
+ if (byDepth !== 0) return byDepth;
204
+ if (leftId === rightId) return 0;
205
+ return leftId < rightId ? -1 : 1;
206
+ }).map(([, prefix]) => prefix);
207
+ }
208
+ /**
209
+ * Compile picked fields into a projection mapping.
210
+ *
211
+ * Throws `PathCompileError` when the picks cannot be expressed — a collection
212
+ * path, a missing primary key, a reserved or duplicated field name. Every
213
+ * mapping it returns satisfies `projectionSchema` and, given a schema whose
214
+ * tables carry rich metadata, `validateProjection`.
215
+ */
216
+ function compilePaths(options) {
217
+ const { entityKey, pickedFields, schema, startTable } = options;
218
+ validatePicks(options);
219
+ const identityColumn = basePrimaryKey(schema, startTable);
220
+ const allocator = new JoinKeyAllocator();
221
+ const joins = requiredPrefixes(pickedFields).map((prefix) => buildJoin(prefix, allocator));
222
+ const fieldBindings = {};
223
+ for (const field of pickedFields) {
224
+ const table = refTableFor(field.path?.edges ?? [], allocator);
225
+ fieldBindings[field.name] = { from: {
226
+ table,
227
+ column: field.column
228
+ } };
229
+ }
230
+ return {
231
+ version: 1,
232
+ kind: "projection",
233
+ entityKey,
234
+ baseTable: startTable,
235
+ joins,
236
+ fieldBindings,
237
+ identity: {
238
+ kind: "base_pk",
239
+ column: identityColumn
240
+ }
241
+ };
242
+ }
243
+ //#endregion
244
+ //#region src/relationship-graph.ts
245
+ const NO_RICH_META_REASON$1 = { code: "no-rich-meta" };
246
+ /** Every edge whose `fromTable` is `table`, in either direction. */
247
+ function edgesFrom(graph, table) {
248
+ return graph.edges.filter((edge) => edge.fromTable === table);
249
+ }
250
+ function childToParentCardinality(parentTable, parentColumn) {
251
+ return require_schema_meta.coversUniqueKey(parentTable, [parentColumn]) ? "to-one" : "to-many";
252
+ }
253
+ function parentToChildCardinality(childTable, childFkColumn) {
254
+ return require_schema_meta.coversUniqueKey(childTable, [childFkColumn]) ? "to-one" : "to-many";
255
+ }
256
+ /**
257
+ * Build the bidirectional relationship graph for a discovered schema.
258
+ *
259
+ * No table, column, or relationship is ever named in this function — the
260
+ * only input is the schema itself.
261
+ */
262
+ function buildRelationshipGraph(schema) {
263
+ const tablesByName = new Map(schema.map((table) => [table.name, table]));
264
+ const nodes = schema.map((table) => require_schema_meta.hasRichMeta(table) ? {
265
+ table: table.name,
266
+ offerable: true
267
+ } : {
268
+ table: table.name,
269
+ offerable: false,
270
+ reason: NO_RICH_META_REASON$1
271
+ });
272
+ const edges = [];
273
+ for (const table of schema) {
274
+ if (!require_schema_meta.hasRichMeta(table)) continue;
275
+ for (const column of table.columnsMeta) {
276
+ const ref = column.references;
277
+ if (!ref) continue;
278
+ const parentTable = tablesByName.get(ref.table);
279
+ if (!(parentTable && require_schema_meta.hasRichMeta(parentTable))) continue;
280
+ edges.push({
281
+ fromTable: table.name,
282
+ fromColumn: column.name,
283
+ toTable: parentTable.name,
284
+ toColumn: ref.column,
285
+ direction: "child-to-parent",
286
+ cardinality: childToParentCardinality(parentTable, ref.column)
287
+ });
288
+ edges.push({
289
+ fromTable: parentTable.name,
290
+ fromColumn: ref.column,
291
+ toTable: table.name,
292
+ toColumn: column.name,
293
+ direction: "parent-to-child",
294
+ cardinality: parentToChildCardinality(table, column.name)
295
+ });
296
+ }
297
+ }
298
+ return {
299
+ nodes,
300
+ edges
301
+ };
302
+ }
303
+ //#endregion
304
+ //#region src/path-enumeration.ts
305
+ /**
306
+ * Breadth-first enumeration of the paths reachable from a starting table.
307
+ *
308
+ * This is the "browse outward" engine behind the derived-object builder: the
309
+ * user picks a starting list and we show them what else is reachable, without
310
+ * ever naming a join, a base table, or an ON condition.
311
+ *
312
+ * Three properties matter enough to be spelled out:
313
+ *
314
+ * 1. **Cycles terminate.** A table already on a path is not revisited, so a
315
+ * self-referencing table yields its one-hop neighbours and stops. Parallel
316
+ * edges are still distinct: edge identity is the FK column pair, not the
317
+ * table pair, so two FKs into the same table stay two relationships.
318
+ *
319
+ * 2. **To-many is sticky.** Once a path crosses a to-many edge, everything at
320
+ * or beyond it is a *collection* — a list of rows, not a single value. Those
321
+ * paths are not pickable columns and must never be compiled into joins; the
322
+ * UI shows them with a "start a list of X" action instead.
323
+ *
324
+ * 3. **Truncation is reported, never silent.** Hitting a budget is part of the
325
+ * return value so the UI can say deeper paths are hidden and offer a
326
+ * narrower search, rather than quietly showing a partial answer as if it
327
+ * were the whole one.
328
+ *
329
+ * No table, column, or relationship is ever named here — the only inputs are
330
+ * the graph and the starting table.
331
+ */
332
+ /** Spec-mandated: how many edges deep a path may go. */
333
+ const MAX_HOPS = 3;
334
+ /** Spec-mandated: how many candidate paths may be considered before stopping. */
335
+ const MAX_CANDIDATES = 200;
336
+ /** Spec-mandated: how many ranked results may be displayed. */
337
+ const MAX_RESULTS = 50;
338
+ /**
339
+ * `direction` is part of the key, not decoration. A column that is both a PK
340
+ * and a self-FK (`t.id` referencing `t.id`) emits two genuinely different
341
+ * edges — one child-to-parent, one parent-to-child — whose table/column
342
+ * halves are identical. Without `direction` they collapse to one key, which
343
+ * makes two distinct paths indistinguishable to the comparator and leaves
344
+ * `Array.prototype.sort` free to order them either way.
345
+ */
346
+ function edgeKey(edge) {
347
+ return `${edge.direction}:${edge.fromTable}.${edge.fromColumn}->${edge.toTable}.${edge.toColumn}`;
348
+ }
349
+ function pathKey(edges) {
350
+ return edges.map(edgeKey).join("|");
351
+ }
352
+ /**
353
+ * Does the path's destination carry a column named exactly `field`?
354
+ *
355
+ * Only an exact match counts. A fuzzy match here would reorder results for
356
+ * reasons a user cannot see, and the ordering is load-bearing downstream.
357
+ */
358
+ function matchesField(path, field, columnsByTable) {
359
+ if (!field) return false;
360
+ const columns = columnsByTable?.get(path.toTable);
361
+ return columns ? columns.includes(field) : false;
362
+ }
363
+ /**
364
+ * Total order over paths: hop count, then exact field match, then the stable
365
+ * key. The key tiebreak is what makes the order total — without it, equal-rank
366
+ * paths would keep their input order, which is stable per run but would drift
367
+ * the moment edge construction changed.
368
+ *
369
+ * Returns 0 on equal keys rather than an arbitrary 1. A comparator that
370
+ * answers 1 for both (a, b) and (b, a) is *inconsistent*, and
371
+ * `Array.prototype.sort` is explicitly allowed to order such elements however
372
+ * it likes — which would make Task 5's ordering invariant flake rather than
373
+ * fail cleanly. Keys are unique in practice now that `edgeKey` carries
374
+ * `direction`, so this is defence against the class, not a live collision.
375
+ */
376
+ function comparePaths(left, right, field, columnsByTable) {
377
+ const byHops = left.edges.length - right.edges.length;
378
+ if (byHops !== 0) return byHops;
379
+ const leftMatch = matchesField(left, field, columnsByTable);
380
+ if (leftMatch !== matchesField(right, field, columnsByTable)) return leftMatch ? -1 : 1;
381
+ if (left.key === right.key) return 0;
382
+ return left.key < right.key ? -1 : 1;
383
+ }
384
+ /**
385
+ * Should this edge be followed from `path`?
386
+ *
387
+ * A self-referencing FK is a real relationship a user browses to (a row's
388
+ * parent row), so it is offered as a one-hop path even though it lands back
389
+ * on the starting table. It is never extended: the destination is already on
390
+ * the path, so the next level rejects it and the cycle terminates.
391
+ */
392
+ function canFollow(entry, edge) {
393
+ const isSelfEdgeFromStart = entry.path.edges.length === 0 && edge.toTable === edge.fromTable;
394
+ return !entry.visited.has(edge.toTable) || isSelfEdgeFromStart;
395
+ }
396
+ function extend(entry, edge) {
397
+ const edges = [...entry.path.edges, edge];
398
+ return {
399
+ path: {
400
+ edges,
401
+ toTable: edge.toTable,
402
+ isCollection: entry.path.isCollection || edge.cardinality === "to-many",
403
+ key: pathKey(edges)
404
+ },
405
+ visited: /* @__PURE__ */ new Set([...entry.visited, edge.toTable])
406
+ };
407
+ }
408
+ /**
409
+ * Expand one breadth-first level, appending to `candidates`.
410
+ *
411
+ * Returns the next frontier, or `undefined` when the candidate budget ran out
412
+ * mid-level — the caller treats that as truncation and stops.
413
+ */
414
+ function expandLevel(graph, frontier, candidates, maxCandidates) {
415
+ const next = [];
416
+ for (const entry of frontier) for (const edge of edgesFrom(graph, entry.path.toTable)) {
417
+ if (!canFollow(entry, edge)) continue;
418
+ if (candidates.length >= maxCandidates) return;
419
+ const extended = extend(entry, edge);
420
+ candidates.push(extended.path);
421
+ next.push(extended);
422
+ }
423
+ return next;
424
+ }
425
+ /**
426
+ * Enumerate every path reachable from `startTable`, ranked and budgeted.
427
+ *
428
+ * Breadth-first, so the candidate budget always cuts the deepest frontier
429
+ * rather than an arbitrary slice of a half-explored level.
430
+ *
431
+ * Note the order of operations: ranking happens *after* the candidate budget
432
+ * has already stopped the walk. A path that would have ranked highly but sits
433
+ * beyond the 200th candidate is therefore never seen, and no amount of
434
+ * ranking recovers it. That is inherent to bounding the search rather than a
435
+ * defect — and it is precisely why truncation is reported, so the UI can
436
+ * offer a narrower search instead of implying the list is complete.
437
+ */
438
+ function enumeratePaths(graph, startTable, options = {}) {
439
+ const maxHops = options.maxHops ?? 3;
440
+ const maxCandidates = options.maxCandidates ?? 200;
441
+ const maxResults = options.maxResults ?? 50;
442
+ const candidates = [];
443
+ let truncatedBy;
444
+ let frontier = [{
445
+ path: {
446
+ edges: [],
447
+ toTable: startTable,
448
+ isCollection: false,
449
+ key: ""
450
+ },
451
+ visited: /* @__PURE__ */ new Set([startTable])
452
+ }];
453
+ for (let hop = 0; hop < maxHops && frontier.length > 0; hop += 1) {
454
+ const next = expandLevel(graph, frontier, candidates, maxCandidates);
455
+ if (!next) {
456
+ truncatedBy = "candidate-budget";
457
+ break;
458
+ }
459
+ frontier = next;
460
+ }
461
+ const columnsByTable = options.columnsByTable;
462
+ const ranked = [...candidates].sort((left, right) => comparePaths(left, right, options.field, columnsByTable));
463
+ const paths = ranked.slice(0, maxResults);
464
+ if (!truncatedBy && ranked.length > maxResults) truncatedBy = "result-limit";
465
+ return {
466
+ paths,
467
+ truncated: Boolean(truncatedBy),
468
+ truncatedBy,
469
+ candidatesConsidered: candidates.length
470
+ };
471
+ }
472
+ //#endregion
473
+ //#region src/starting-record-change.ts
474
+ /** Does this table have a column of exactly this name? */
475
+ function hasColumn(schema, table, column) {
476
+ const found = schema.find((entry) => entry.name === table);
477
+ return found ? found.columns.includes(column) : false;
478
+ }
479
+ /**
480
+ * The route a pick would have from the new starting record, or `null` when it
481
+ * has none.
482
+ *
483
+ * Returns `{ path: undefined }` — deliberately distinct from `null` — when the
484
+ * field is a column of the new starting record itself. That is a survivor
485
+ * reached by no route, which is exactly what `PickedField.path` being absent
486
+ * means to `compilePaths`.
487
+ */
488
+ function repoint(schema, paths, toTable, pick) {
489
+ if (hasColumn(schema, toTable, pick.column)) return { path: void 0 };
490
+ const destination = pick.path?.toTable ?? void 0;
491
+ const candidates = paths.filter((path) => !path.isCollection);
492
+ if (destination !== void 0) {
493
+ const match = candidates.find((path) => path.toTable === destination && hasColumn(schema, path.toTable, pick.column));
494
+ return match ? { path: match } : null;
495
+ }
496
+ const match = candidates.find((path) => hasColumn(schema, path.toTable, pick.column));
497
+ return match ? { path: match } : null;
498
+ }
499
+ /**
500
+ * Identify the connection a pick travelled over, for counting.
501
+ *
502
+ * The path key, not the destination table: two FKs from one table into the
503
+ * same table — a ticket's assignee and its reviewer — are two genuinely
504
+ * different connections that both end at `users`. Counting by destination
505
+ * would collapse them into one and under-report what the user loses.
506
+ *
507
+ * `null` means the pick was a column of the starting record, which is not a
508
+ * connection.
509
+ */
510
+ function connectionOf(pick) {
511
+ if (!pick.path || pick.path.edges.length === 0) return null;
512
+ return pick.path.key;
513
+ }
514
+ /**
515
+ * Classify what changing the starting record does to a set of picked fields.
516
+ *
517
+ * Pure and total: an unknown `to` table simply has no reachable paths, so
518
+ * every pick that is not one of its own columns is reported removed. It never
519
+ * throws, because its caller is a confirmation dialog that must be able to
520
+ * render an answer for any proposed change, including a nonsensical one.
521
+ */
522
+ function classifyStartingRecordChange(options) {
523
+ const { from, picks, schema, to } = options;
524
+ if (from === to) return {
525
+ keptFields: [...picks],
526
+ removedConnectionCount: 0,
527
+ removedFields: []
528
+ };
529
+ const { paths } = enumeratePaths(buildRelationshipGraph(schema), to);
530
+ const keptFields = [];
531
+ const removedFields = [];
532
+ const removedConnections = /* @__PURE__ */ new Set();
533
+ for (const pick of picks) {
534
+ const repointed = repoint(schema, paths, to, pick);
535
+ if (repointed) {
536
+ keptFields.push({
537
+ column: pick.column,
538
+ name: pick.name,
539
+ ...repointed.path ? { path: repointed.path } : {}
540
+ });
541
+ continue;
542
+ }
543
+ removedFields.push(pick);
544
+ const connection = connectionOf(pick);
545
+ if (connection !== null) removedConnections.add(connection);
546
+ }
547
+ return {
548
+ keptFields,
549
+ removedConnectionCount: removedConnections.size,
550
+ removedFields
551
+ };
552
+ }
553
+ //#endregion
554
+ //#region src/starting-record-eligibility.ts
555
+ const NO_RICH_META_REASON = { code: "no-rich-meta" };
556
+ const COMPOSITE_PK_REASON = { code: "composite-pk" };
557
+ const NO_PRIMARY_KEY_REASON = { code: "no-primary-key" };
558
+ /**
559
+ * List every table in the discovered schema with its starting-record
560
+ * eligibility, in schema order.
561
+ *
562
+ * No table, column, or relationship is ever named in this function — the
563
+ * only input is the schema itself.
564
+ */
565
+ function listStartingRecordOptions(schema) {
566
+ return schema.map((table) => {
567
+ if (!require_schema_meta.hasRichMeta(table)) return {
568
+ table: table.name,
569
+ offerable: false,
570
+ reason: NO_RICH_META_REASON
571
+ };
572
+ const pk = require_schema_meta.primaryKeyColumns(table);
573
+ if (pk.length > 1) return {
574
+ table: table.name,
575
+ offerable: false,
576
+ reason: COMPOSITE_PK_REASON
577
+ };
578
+ if (pk.length === 0) return {
579
+ table: table.name,
580
+ offerable: false,
581
+ reason: NO_PRIMARY_KEY_REASON
582
+ };
583
+ return {
584
+ table: table.name,
585
+ offerable: true
586
+ };
587
+ });
588
+ }
589
+ //#endregion
590
+ exports.MAX_CANDIDATES = MAX_CANDIDATES;
591
+ exports.MAX_HOPS = MAX_HOPS;
592
+ exports.MAX_RESULTS = MAX_RESULTS;
593
+ exports.PathCompileError = PathCompileError;
594
+ exports.buildRelationshipGraph = buildRelationshipGraph;
595
+ exports.classifyStartingRecordChange = classifyStartingRecordChange;
596
+ exports.compilePaths = compilePaths;
597
+ exports.edgesFrom = edgesFrom;
598
+ exports.enumeratePaths = enumeratePaths;
599
+ exports.listStartingRecordOptions = listStartingRecordOptions;