@clivly/core 0.9.7 → 0.10.1

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