@clivly/core 0.8.0 → 0.9.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 +182 -16
  2. package/dist/drizzle.cjs +82 -1
  3. package/dist/drizzle.d.cts +23 -2
  4. package/dist/drizzle.d.mts +23 -2
  5. package/dist/drizzle.mjs +81 -2
  6. package/dist/entity-config-8Fgr6IOH.mjs +666 -0
  7. package/dist/entity-config-CLxI8dVb.d.cts +556 -0
  8. package/dist/entity-config-CLxI8dVb.d.mts +556 -0
  9. package/dist/entity-config-Dh8aEcgj.cjs +779 -0
  10. package/dist/entity-config.cjs +13 -349
  11. package/dist/entity-config.d.cts +2 -295
  12. package/dist/entity-config.d.mts +2 -295
  13. package/dist/entity-config.mjs +2 -339
  14. package/dist/entity-heuristics.cjs +28 -8
  15. package/dist/entity-heuristics.d.cts +2 -12
  16. package/dist/entity-heuristics.d.mts +2 -12
  17. package/dist/entity-heuristics.mjs +28 -8
  18. package/dist/env-file.cjs +59 -0
  19. package/dist/env-file.d.cts +23 -0
  20. package/dist/env-file.d.mts +23 -0
  21. package/dist/env-file.mjs +55 -0
  22. package/dist/index.cjs +83 -2
  23. package/dist/index.d.cts +72 -2
  24. package/dist/index.d.mts +72 -2
  25. package/dist/index.mjs +69 -3
  26. package/dist/integration-state.cjs +242 -0
  27. package/dist/integration-state.d.cts +88 -0
  28. package/dist/integration-state.d.mts +88 -0
  29. package/dist/integration-state.mjs +232 -0
  30. package/dist/mapping-form.d.cts +1 -1
  31. package/dist/mapping-form.d.mts +1 -1
  32. package/dist/mappings-config.cjs +32 -6
  33. package/dist/mappings-config.d.cts +7 -1
  34. package/dist/mappings-config.d.mts +7 -1
  35. package/dist/mappings-config.mjs +32 -6
  36. package/dist/projection-BXBkh7qp.cjs +98 -0
  37. package/dist/projection-C_EMRmZ8.mjs +75 -0
  38. package/dist/sync-engine.cjs +9 -2
  39. package/dist/sync-engine.d.cts +1 -1
  40. package/dist/sync-engine.d.mts +1 -1
  41. package/dist/sync-engine.mjs +9 -2
  42. package/dist/view-compiler-BpNI8Ttw.cjs +299 -0
  43. package/dist/view-compiler-CcqsGxv4.mjs +270 -0
  44. package/dist/view-compiler.cjs +6 -188
  45. package/dist/view-compiler.d.cts +12 -2
  46. package/dist/view-compiler.d.mts +12 -2
  47. package/dist/view-compiler.mjs +2 -185
  48. package/package.json +21 -1
@@ -0,0 +1,299 @@
1
+ const require_projection = require("./projection-BXBkh7qp.cjs");
2
+ //#region src/projection-sql.ts
3
+ /**
4
+ * SQL generation for projection-backed entities.
5
+ *
6
+ * Base and joins are aliased by their ref key (`base`, then each join's `key`),
7
+ * which is what allows the same table to be joined twice and keeps generated
8
+ * SQL independent of host table names.
9
+ *
10
+ * Pure string generation — no DB access. Identifiers are escaped so output is
11
+ * deterministic and injection-safe.
12
+ */
13
+ /** Quote a single identifier part, escaping embedded double-quotes. */
14
+ function quoteIdent(name) {
15
+ return `"${name.replace(/"/g, "\"\"")}"`;
16
+ }
17
+ function refSql(ref) {
18
+ return `${quoteIdent(ref.table)}.${quoteIdent(ref.column)}`;
19
+ }
20
+ /**
21
+ * The identity expression, aliased as `id`. Composite identities concatenate
22
+ * their parts with `::`; validation guarantees every part is NOT NULL and
23
+ * non-text, which is what makes the concatenation collision-free.
24
+ */
25
+ function identitySql(projection, basePkColumn) {
26
+ const refs = require_projection.identityRefs(projection.identity, basePkColumn);
27
+ if (projection.identity.kind === "composite") return `CONCAT_WS('::', ${refs.map((ref) => `${refSql(ref)}::text`).join(", ")}) AS ${quoteIdent("id")}`;
28
+ const [ref] = refs;
29
+ if (!ref) throw new Error("projection identity resolved to no column");
30
+ return `${refSql(ref)} AS ${quoteIdent("id")}`;
31
+ }
32
+ function compileProjectionSelect(projection, basePkColumn) {
33
+ const selectItems = [identitySql(projection, basePkColumn)];
34
+ const columns = ["id"];
35
+ for (const [field, binding] of Object.entries(projection.fieldBindings)) {
36
+ selectItems.push(`${refSql(binding.from)} AS ${quoteIdent(field)}`);
37
+ columns.push(field);
38
+ }
39
+ return {
40
+ columns,
41
+ selectItems
42
+ };
43
+ }
44
+ function compileProjectionJoins(projection) {
45
+ return projection.joins.map((join) => {
46
+ const keyword = join.type === "inner" ? "INNER JOIN" : "LEFT JOIN";
47
+ const on = join.on.map((c) => `${refSql(c.left)} = ${refSql(c.right)}`).join(" AND ");
48
+ return `${keyword} ${quoteIdent(join.table)} AS ${quoteIdent(join.key)} ON ${on}`;
49
+ });
50
+ }
51
+ /** `FROM <baseTable> AS "base"` — the alias every ref resolves against. */
52
+ function compileProjectionFrom(projection) {
53
+ return `FROM ${quoteIdent(projection.baseTable)} AS ${quoteIdent(require_projection.BASE_REF)}`;
54
+ }
55
+ //#endregion
56
+ //#region src/view-compiler.ts
57
+ /**
58
+ * View compiler (Phase 2) — turns a validated entity config into read-only
59
+ * Postgres view DDL.
60
+ *
61
+ * Each entity compiles to `crm_src_<entity>`: a projection over the host table
62
+ * that (a) selects the source primary key as `id` (the sync engine's
63
+ * `source_ref`), (b) aliases mapped columns to their Clivly field names,
64
+ * (c) resolves each relationship to a `<relationship>_ref` column carrying the
65
+ * host-side key, and (d) applies the entity's filter as a WHERE clause.
66
+ *
67
+ * The view is the security boundary: it exposes only allow-listed columns, and
68
+ * the sync engine (Phase 3) reads exclusively from it — never the raw table.
69
+ * Relationship refs stay host-side keys here; Phase 3 maps `<rel>_ref` onto the
70
+ * corresponding `crm_companies.id` mirror row.
71
+ *
72
+ * Pure string generation — no DB access. Config is developer-authored, but all
73
+ * identifiers and literals are still escaped so output is deterministic and
74
+ * injection-safe.
75
+ */
76
+ /** The source primary-key column. Assumed `id`; the sync engine reads it as `source_ref`. */
77
+ const SOURCE_KEY = "id";
78
+ const DEFAULT_VIEW_PREFIX = "crm_src_";
79
+ const DEFAULT_ROLE = "primary";
80
+ /** Emit a SQL literal for a scalar filter value. */
81
+ function quoteLiteral(value) {
82
+ if (typeof value === "number") return String(value);
83
+ if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
84
+ return `'${value.replace(/'/g, "''")}'`;
85
+ }
86
+ function quoteList(values) {
87
+ return `(${values.map((v) => quoteLiteral(v)).join(", ")})`;
88
+ }
89
+ /** Compile one column predicate into a SQL boolean expression. */
90
+ function compilePredicate(table, column, value) {
91
+ const col = `${quoteIdent(table)}.${quoteIdent(column)}`;
92
+ if (value === null || typeof value !== "object") return `${col} = ${quoteLiteral(value)}`;
93
+ if ("eq" in value) return `${col} = ${quoteLiteral(value.eq)}`;
94
+ if ("ne" in value) return `${col} <> ${quoteLiteral(value.ne)}`;
95
+ if ("in" in value) return `${col} IN ${quoteList(value.in)}`;
96
+ if ("notIn" in value) return `${col} NOT IN ${quoteList(value.notIn)}`;
97
+ if ("isNull" in value) return `${col} IS NULL`;
98
+ if ("isNotNull" in value) return `${col} IS NOT NULL`;
99
+ throw new Error(`unsupported filter predicate on ${table}.${column}`);
100
+ }
101
+ /**
102
+ * Build the WHERE conditions for an entity. Returns an array of SQL fragments
103
+ * (ANDed by the caller). `$raw` filters pass through verbatim.
104
+ */
105
+ function compileFilterConditions(table, filter) {
106
+ if (!filter) return [];
107
+ if ("$raw" in filter) return [`(${filter.$raw})`];
108
+ return Object.entries(filter).map(([column, value]) => compilePredicate(table, column, value));
109
+ }
110
+ /** Split a `table.column` ref, defaulting the table when unqualified. */
111
+ function refParts(ref, defaultTable) {
112
+ const dot = ref.indexOf(".");
113
+ if (dot === -1) return {
114
+ table: defaultTable,
115
+ column: ref
116
+ };
117
+ return {
118
+ table: ref.slice(0, dot),
119
+ column: ref.slice(dot + 1)
120
+ };
121
+ }
122
+ function compileRelationship(relName, rel, source, related) {
123
+ const refCol = `${relName}_ref`;
124
+ const refAlias = quoteIdent(refCol);
125
+ const { via } = rel;
126
+ if ("fkOn" in via) {
127
+ const relatedTable = related?.source ?? "";
128
+ if (via.fkOn === "company") {
129
+ const { table, column } = refParts(via.column, relatedTable);
130
+ const relatedFilter = compileFilterConditions(table, related?.filter);
131
+ const on = [`${quoteIdent(table)}.${quoteIdent(column)} = ${quoteIdent(source.source)}.${quoteIdent(SOURCE_KEY)}`, ...relatedFilter].join(" AND ");
132
+ return {
133
+ clause: `LEFT JOIN ${quoteIdent(table)} ON ${on}`,
134
+ refSelect: `${quoteIdent(table)}.${quoteIdent(SOURCE_KEY)} AS ${refAlias}`,
135
+ refColumn: refCol
136
+ };
137
+ }
138
+ const { table, column } = refParts(via.column, source.source);
139
+ const on = [`${quoteIdent(relatedTable)}.${quoteIdent(SOURCE_KEY)} = ${quoteIdent(table)}.${quoteIdent(column)}`, ...compileFilterConditions(relatedTable, related?.filter)].join(" AND ");
140
+ return {
141
+ clause: `LEFT JOIN ${quoteIdent(relatedTable)} ON ${on}`,
142
+ refSelect: `${quoteIdent(relatedTable)}.${quoteIdent(SOURCE_KEY)} AS ${refAlias}`,
143
+ refColumn: refCol
144
+ };
145
+ }
146
+ if ("through" in via) {
147
+ const on = `${quoteIdent(via.through)}.${quoteIdent(via.localKey)} = ${quoteIdent(source.source)}.${quoteIdent(SOURCE_KEY)}`;
148
+ return {
149
+ clause: `LEFT JOIN ${quoteIdent(via.through)} ON ${on}`,
150
+ refSelect: `${quoteIdent(via.through)}.${quoteIdent(via.foreignKey)} AS ${refAlias}`,
151
+ refColumn: refCol
152
+ };
153
+ }
154
+ return {
155
+ clause: via.$raw,
156
+ refSelect: null,
157
+ refColumn: refCol
158
+ };
159
+ }
160
+ /** SELECT items for a legacy single-table entity: source key, then fields. */
161
+ function legacySelect(entity, src) {
162
+ const selectItems = [`${quoteIdent(src)}.${quoteIdent(SOURCE_KEY)} AS ${quoteIdent("id")}`];
163
+ const columns = ["id"];
164
+ for (const [field, column] of Object.entries(entity.fields)) {
165
+ selectItems.push(`${quoteIdent(src)}.${quoteIdent(column)} AS ${quoteIdent(field)}`);
166
+ columns.push(field);
167
+ }
168
+ return {
169
+ columns,
170
+ selectItems
171
+ };
172
+ }
173
+ /**
174
+ * Relationship JOINs for a legacy entity. Appends each relationship's ref
175
+ * column to `columns`/`selectItems` in place, preserving the existing order.
176
+ */
177
+ function legacyJoins(config, entity, columns, selectItems) {
178
+ const joins = [];
179
+ for (const [relName, rel] of Object.entries(entity.relationships ?? {})) {
180
+ const compiled = compileRelationship(relName, rel, entity, config.entities[rel.entity]);
181
+ joins.push(compiled.clause);
182
+ if (compiled.refSelect) {
183
+ selectItems.push(compiled.refSelect);
184
+ columns.push(compiled.refColumn);
185
+ }
186
+ }
187
+ return joins;
188
+ }
189
+ /**
190
+ * Translate a driver error from executing a compiled view into something
191
+ * actionable, or `undefined` when it is not one we recognise (caller rethrows).
192
+ *
193
+ * The case that matters: `WITH (security_invoker = true)` needs Postgres 15,
194
+ * and PG 14 rejects it with sqlstate 22023 naming a Postgres internal
195
+ * (`reloptions.c`) — no mention of Clivly, the option, or the version. The
196
+ * compiler cannot catch this itself because the host executes the SQL.
197
+ */
198
+ function explainViewExecutionError(error) {
199
+ if (typeof error !== "object" || error === null) return;
200
+ const { code, routine } = error;
201
+ const message = error instanceof Error ? error.message : "";
202
+ if (!(code === "22023" && (routine === "parseRelOptionsInternal" || message.includes("security_invoker")))) return;
203
+ return "CREATE VIEW … WITH (security_invoker = true) requires Postgres 15+. Pass `securityInvoker: false` to compile without it — but note the view then runs with its owner's privileges instead of the querying role's, which bypasses row-level security (RLS) on the underlying tables. Prefer upgrading Postgres if you rely on RLS to separate tenants.";
204
+ }
205
+ function compileEntityView(config, entityKey, options = {}) {
206
+ const entity = config.entities[entityKey];
207
+ if (!entity) throw new Error(`entity "${entityKey}" is not defined`);
208
+ const viewName = `${options.viewPrefix ?? "crm_src_"}${entityKey}`;
209
+ const src = entity.source;
210
+ const projection = entity.projection;
211
+ const filterTable = projection ? require_projection.BASE_REF : src;
212
+ const { selectItems, columns } = projection ? compileProjectionSelect(projection, SOURCE_KEY) : legacySelect(entity, src);
213
+ const joins = projection ? compileProjectionJoins(projection) : legacyJoins(config, entity, columns, selectItems);
214
+ const conditions = compileFilterConditions(filterTable, entity.filter);
215
+ if (options.orgScope) conditions.push(`${quoteIdent(filterTable)}.${quoteIdent(options.orgScope.column)} = ${quoteLiteral(options.orgScope.value)}`);
216
+ const lines = [
217
+ options.securityInvoker ?? true ? `CREATE OR REPLACE VIEW ${quoteIdent(viewName)} WITH (security_invoker = true) AS` : `CREATE OR REPLACE VIEW ${quoteIdent(viewName)} AS`,
218
+ `SELECT ${selectItems.join(", ")}`,
219
+ projection ? compileProjectionFrom(projection) : `FROM ${quoteIdent(src)}`,
220
+ ...joins
221
+ ];
222
+ if (conditions.length > 0) lines.push(`WHERE ${conditions.join(" AND ")}`);
223
+ const sql = `${lines.join("\n")};`;
224
+ return {
225
+ entityKey,
226
+ viewName,
227
+ concept: entity.concept,
228
+ role: entity.role ?? DEFAULT_ROLE,
229
+ sourceTable: src,
230
+ columns,
231
+ sql,
232
+ dropSql: `DROP VIEW IF EXISTS ${quoteIdent(viewName)};`
233
+ };
234
+ }
235
+ /** Compile every entity in the config into its view, in declaration order. */
236
+ function compileEntityViews(config, options = {}) {
237
+ return Object.keys(config.entities).map((key) => compileEntityView(config, key, options));
238
+ }
239
+ /**
240
+ * Dry-run each entity's compiled view against the host DB via a caller-provided
241
+ * `run` function, so `$raw` filter/join fragments — which skip schema validation
242
+ * (see `validateEntitiesConfig`) — fail loudly *before* the first sync instead of
243
+ * at sync time. Never throws: each view's outcome is collected into an
244
+ * `ExplainResult`, so a mapping UI or CLI can show every problem at once.
245
+ *
246
+ * `run` should execute the SQL somewhere side-effect-free — e.g. inside a
247
+ * transaction it rolls back, or by `EXPLAIN`-ing the statement — since the
248
+ * compiled SQL is a `CREATE OR REPLACE VIEW`.
249
+ */
250
+ async function explainEntitiesConfig(config, run, options = {}) {
251
+ const results = [];
252
+ for (const view of compileEntityViews(config, options)) try {
253
+ await run(view.sql);
254
+ results.push({
255
+ entityKey: view.entityKey,
256
+ ok: true,
257
+ sql: view.sql
258
+ });
259
+ } catch (error) {
260
+ results.push({
261
+ entityKey: view.entityKey,
262
+ ok: false,
263
+ sql: view.sql,
264
+ error: error instanceof Error ? error.message : String(error)
265
+ });
266
+ }
267
+ return results;
268
+ }
269
+ //#endregion
270
+ Object.defineProperty(exports, "DEFAULT_VIEW_PREFIX", {
271
+ enumerable: true,
272
+ get: function() {
273
+ return DEFAULT_VIEW_PREFIX;
274
+ }
275
+ });
276
+ Object.defineProperty(exports, "compileEntityView", {
277
+ enumerable: true,
278
+ get: function() {
279
+ return compileEntityView;
280
+ }
281
+ });
282
+ Object.defineProperty(exports, "compileEntityViews", {
283
+ enumerable: true,
284
+ get: function() {
285
+ return compileEntityViews;
286
+ }
287
+ });
288
+ Object.defineProperty(exports, "explainEntitiesConfig", {
289
+ enumerable: true,
290
+ get: function() {
291
+ return explainEntitiesConfig;
292
+ }
293
+ });
294
+ Object.defineProperty(exports, "explainViewExecutionError", {
295
+ enumerable: true,
296
+ get: function() {
297
+ return explainViewExecutionError;
298
+ }
299
+ });
@@ -0,0 +1,270 @@
1
+ import { r as identityRefs, t as BASE_REF } from "./projection-C_EMRmZ8.mjs";
2
+ //#region src/projection-sql.ts
3
+ /**
4
+ * SQL generation for projection-backed entities.
5
+ *
6
+ * Base and joins are aliased by their ref key (`base`, then each join's `key`),
7
+ * which is what allows the same table to be joined twice and keeps generated
8
+ * SQL independent of host table names.
9
+ *
10
+ * Pure string generation — no DB access. Identifiers are escaped so output is
11
+ * deterministic and injection-safe.
12
+ */
13
+ /** Quote a single identifier part, escaping embedded double-quotes. */
14
+ function quoteIdent(name) {
15
+ return `"${name.replace(/"/g, "\"\"")}"`;
16
+ }
17
+ function refSql(ref) {
18
+ return `${quoteIdent(ref.table)}.${quoteIdent(ref.column)}`;
19
+ }
20
+ /**
21
+ * The identity expression, aliased as `id`. Composite identities concatenate
22
+ * their parts with `::`; validation guarantees every part is NOT NULL and
23
+ * non-text, which is what makes the concatenation collision-free.
24
+ */
25
+ function identitySql(projection, basePkColumn) {
26
+ const refs = identityRefs(projection.identity, basePkColumn);
27
+ if (projection.identity.kind === "composite") return `CONCAT_WS('::', ${refs.map((ref) => `${refSql(ref)}::text`).join(", ")}) AS ${quoteIdent("id")}`;
28
+ const [ref] = refs;
29
+ if (!ref) throw new Error("projection identity resolved to no column");
30
+ return `${refSql(ref)} AS ${quoteIdent("id")}`;
31
+ }
32
+ function compileProjectionSelect(projection, basePkColumn) {
33
+ const selectItems = [identitySql(projection, basePkColumn)];
34
+ const columns = ["id"];
35
+ for (const [field, binding] of Object.entries(projection.fieldBindings)) {
36
+ selectItems.push(`${refSql(binding.from)} AS ${quoteIdent(field)}`);
37
+ columns.push(field);
38
+ }
39
+ return {
40
+ columns,
41
+ selectItems
42
+ };
43
+ }
44
+ function compileProjectionJoins(projection) {
45
+ return projection.joins.map((join) => {
46
+ const keyword = join.type === "inner" ? "INNER JOIN" : "LEFT JOIN";
47
+ const on = join.on.map((c) => `${refSql(c.left)} = ${refSql(c.right)}`).join(" AND ");
48
+ return `${keyword} ${quoteIdent(join.table)} AS ${quoteIdent(join.key)} ON ${on}`;
49
+ });
50
+ }
51
+ /** `FROM <baseTable> AS "base"` — the alias every ref resolves against. */
52
+ function compileProjectionFrom(projection) {
53
+ return `FROM ${quoteIdent(projection.baseTable)} AS ${quoteIdent(BASE_REF)}`;
54
+ }
55
+ //#endregion
56
+ //#region src/view-compiler.ts
57
+ /**
58
+ * View compiler (Phase 2) — turns a validated entity config into read-only
59
+ * Postgres view DDL.
60
+ *
61
+ * Each entity compiles to `crm_src_<entity>`: a projection over the host table
62
+ * that (a) selects the source primary key as `id` (the sync engine's
63
+ * `source_ref`), (b) aliases mapped columns to their Clivly field names,
64
+ * (c) resolves each relationship to a `<relationship>_ref` column carrying the
65
+ * host-side key, and (d) applies the entity's filter as a WHERE clause.
66
+ *
67
+ * The view is the security boundary: it exposes only allow-listed columns, and
68
+ * the sync engine (Phase 3) reads exclusively from it — never the raw table.
69
+ * Relationship refs stay host-side keys here; Phase 3 maps `<rel>_ref` onto the
70
+ * corresponding `crm_companies.id` mirror row.
71
+ *
72
+ * Pure string generation — no DB access. Config is developer-authored, but all
73
+ * identifiers and literals are still escaped so output is deterministic and
74
+ * injection-safe.
75
+ */
76
+ /** The source primary-key column. Assumed `id`; the sync engine reads it as `source_ref`. */
77
+ const SOURCE_KEY = "id";
78
+ const DEFAULT_VIEW_PREFIX = "crm_src_";
79
+ const DEFAULT_ROLE = "primary";
80
+ /** Emit a SQL literal for a scalar filter value. */
81
+ function quoteLiteral(value) {
82
+ if (typeof value === "number") return String(value);
83
+ if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
84
+ return `'${value.replace(/'/g, "''")}'`;
85
+ }
86
+ function quoteList(values) {
87
+ return `(${values.map((v) => quoteLiteral(v)).join(", ")})`;
88
+ }
89
+ /** Compile one column predicate into a SQL boolean expression. */
90
+ function compilePredicate(table, column, value) {
91
+ const col = `${quoteIdent(table)}.${quoteIdent(column)}`;
92
+ if (value === null || typeof value !== "object") return `${col} = ${quoteLiteral(value)}`;
93
+ if ("eq" in value) return `${col} = ${quoteLiteral(value.eq)}`;
94
+ if ("ne" in value) return `${col} <> ${quoteLiteral(value.ne)}`;
95
+ if ("in" in value) return `${col} IN ${quoteList(value.in)}`;
96
+ if ("notIn" in value) return `${col} NOT IN ${quoteList(value.notIn)}`;
97
+ if ("isNull" in value) return `${col} IS NULL`;
98
+ if ("isNotNull" in value) return `${col} IS NOT NULL`;
99
+ throw new Error(`unsupported filter predicate on ${table}.${column}`);
100
+ }
101
+ /**
102
+ * Build the WHERE conditions for an entity. Returns an array of SQL fragments
103
+ * (ANDed by the caller). `$raw` filters pass through verbatim.
104
+ */
105
+ function compileFilterConditions(table, filter) {
106
+ if (!filter) return [];
107
+ if ("$raw" in filter) return [`(${filter.$raw})`];
108
+ return Object.entries(filter).map(([column, value]) => compilePredicate(table, column, value));
109
+ }
110
+ /** Split a `table.column` ref, defaulting the table when unqualified. */
111
+ function refParts(ref, defaultTable) {
112
+ const dot = ref.indexOf(".");
113
+ if (dot === -1) return {
114
+ table: defaultTable,
115
+ column: ref
116
+ };
117
+ return {
118
+ table: ref.slice(0, dot),
119
+ column: ref.slice(dot + 1)
120
+ };
121
+ }
122
+ function compileRelationship(relName, rel, source, related) {
123
+ const refCol = `${relName}_ref`;
124
+ const refAlias = quoteIdent(refCol);
125
+ const { via } = rel;
126
+ if ("fkOn" in via) {
127
+ const relatedTable = related?.source ?? "";
128
+ if (via.fkOn === "company") {
129
+ const { table, column } = refParts(via.column, relatedTable);
130
+ const relatedFilter = compileFilterConditions(table, related?.filter);
131
+ const on = [`${quoteIdent(table)}.${quoteIdent(column)} = ${quoteIdent(source.source)}.${quoteIdent(SOURCE_KEY)}`, ...relatedFilter].join(" AND ");
132
+ return {
133
+ clause: `LEFT JOIN ${quoteIdent(table)} ON ${on}`,
134
+ refSelect: `${quoteIdent(table)}.${quoteIdent(SOURCE_KEY)} AS ${refAlias}`,
135
+ refColumn: refCol
136
+ };
137
+ }
138
+ const { table, column } = refParts(via.column, source.source);
139
+ const on = [`${quoteIdent(relatedTable)}.${quoteIdent(SOURCE_KEY)} = ${quoteIdent(table)}.${quoteIdent(column)}`, ...compileFilterConditions(relatedTable, related?.filter)].join(" AND ");
140
+ return {
141
+ clause: `LEFT JOIN ${quoteIdent(relatedTable)} ON ${on}`,
142
+ refSelect: `${quoteIdent(relatedTable)}.${quoteIdent(SOURCE_KEY)} AS ${refAlias}`,
143
+ refColumn: refCol
144
+ };
145
+ }
146
+ if ("through" in via) {
147
+ const on = `${quoteIdent(via.through)}.${quoteIdent(via.localKey)} = ${quoteIdent(source.source)}.${quoteIdent(SOURCE_KEY)}`;
148
+ return {
149
+ clause: `LEFT JOIN ${quoteIdent(via.through)} ON ${on}`,
150
+ refSelect: `${quoteIdent(via.through)}.${quoteIdent(via.foreignKey)} AS ${refAlias}`,
151
+ refColumn: refCol
152
+ };
153
+ }
154
+ return {
155
+ clause: via.$raw,
156
+ refSelect: null,
157
+ refColumn: refCol
158
+ };
159
+ }
160
+ /** SELECT items for a legacy single-table entity: source key, then fields. */
161
+ function legacySelect(entity, src) {
162
+ const selectItems = [`${quoteIdent(src)}.${quoteIdent(SOURCE_KEY)} AS ${quoteIdent("id")}`];
163
+ const columns = ["id"];
164
+ for (const [field, column] of Object.entries(entity.fields)) {
165
+ selectItems.push(`${quoteIdent(src)}.${quoteIdent(column)} AS ${quoteIdent(field)}`);
166
+ columns.push(field);
167
+ }
168
+ return {
169
+ columns,
170
+ selectItems
171
+ };
172
+ }
173
+ /**
174
+ * Relationship JOINs for a legacy entity. Appends each relationship's ref
175
+ * column to `columns`/`selectItems` in place, preserving the existing order.
176
+ */
177
+ function legacyJoins(config, entity, columns, selectItems) {
178
+ const joins = [];
179
+ for (const [relName, rel] of Object.entries(entity.relationships ?? {})) {
180
+ const compiled = compileRelationship(relName, rel, entity, config.entities[rel.entity]);
181
+ joins.push(compiled.clause);
182
+ if (compiled.refSelect) {
183
+ selectItems.push(compiled.refSelect);
184
+ columns.push(compiled.refColumn);
185
+ }
186
+ }
187
+ return joins;
188
+ }
189
+ /**
190
+ * Translate a driver error from executing a compiled view into something
191
+ * actionable, or `undefined` when it is not one we recognise (caller rethrows).
192
+ *
193
+ * The case that matters: `WITH (security_invoker = true)` needs Postgres 15,
194
+ * and PG 14 rejects it with sqlstate 22023 naming a Postgres internal
195
+ * (`reloptions.c`) — no mention of Clivly, the option, or the version. The
196
+ * compiler cannot catch this itself because the host executes the SQL.
197
+ */
198
+ function explainViewExecutionError(error) {
199
+ if (typeof error !== "object" || error === null) return;
200
+ const { code, routine } = error;
201
+ const message = error instanceof Error ? error.message : "";
202
+ if (!(code === "22023" && (routine === "parseRelOptionsInternal" || message.includes("security_invoker")))) return;
203
+ return "CREATE VIEW … WITH (security_invoker = true) requires Postgres 15+. Pass `securityInvoker: false` to compile without it — but note the view then runs with its owner's privileges instead of the querying role's, which bypasses row-level security (RLS) on the underlying tables. Prefer upgrading Postgres if you rely on RLS to separate tenants.";
204
+ }
205
+ function compileEntityView(config, entityKey, options = {}) {
206
+ const entity = config.entities[entityKey];
207
+ if (!entity) throw new Error(`entity "${entityKey}" is not defined`);
208
+ const viewName = `${options.viewPrefix ?? "crm_src_"}${entityKey}`;
209
+ const src = entity.source;
210
+ const projection = entity.projection;
211
+ const filterTable = projection ? BASE_REF : src;
212
+ const { selectItems, columns } = projection ? compileProjectionSelect(projection, SOURCE_KEY) : legacySelect(entity, src);
213
+ const joins = projection ? compileProjectionJoins(projection) : legacyJoins(config, entity, columns, selectItems);
214
+ const conditions = compileFilterConditions(filterTable, entity.filter);
215
+ if (options.orgScope) conditions.push(`${quoteIdent(filterTable)}.${quoteIdent(options.orgScope.column)} = ${quoteLiteral(options.orgScope.value)}`);
216
+ const lines = [
217
+ options.securityInvoker ?? true ? `CREATE OR REPLACE VIEW ${quoteIdent(viewName)} WITH (security_invoker = true) AS` : `CREATE OR REPLACE VIEW ${quoteIdent(viewName)} AS`,
218
+ `SELECT ${selectItems.join(", ")}`,
219
+ projection ? compileProjectionFrom(projection) : `FROM ${quoteIdent(src)}`,
220
+ ...joins
221
+ ];
222
+ if (conditions.length > 0) lines.push(`WHERE ${conditions.join(" AND ")}`);
223
+ const sql = `${lines.join("\n")};`;
224
+ return {
225
+ entityKey,
226
+ viewName,
227
+ concept: entity.concept,
228
+ role: entity.role ?? DEFAULT_ROLE,
229
+ sourceTable: src,
230
+ columns,
231
+ sql,
232
+ dropSql: `DROP VIEW IF EXISTS ${quoteIdent(viewName)};`
233
+ };
234
+ }
235
+ /** Compile every entity in the config into its view, in declaration order. */
236
+ function compileEntityViews(config, options = {}) {
237
+ return Object.keys(config.entities).map((key) => compileEntityView(config, key, options));
238
+ }
239
+ /**
240
+ * Dry-run each entity's compiled view against the host DB via a caller-provided
241
+ * `run` function, so `$raw` filter/join fragments — which skip schema validation
242
+ * (see `validateEntitiesConfig`) — fail loudly *before* the first sync instead of
243
+ * at sync time. Never throws: each view's outcome is collected into an
244
+ * `ExplainResult`, so a mapping UI or CLI can show every problem at once.
245
+ *
246
+ * `run` should execute the SQL somewhere side-effect-free — e.g. inside a
247
+ * transaction it rolls back, or by `EXPLAIN`-ing the statement — since the
248
+ * compiled SQL is a `CREATE OR REPLACE VIEW`.
249
+ */
250
+ async function explainEntitiesConfig(config, run, options = {}) {
251
+ const results = [];
252
+ for (const view of compileEntityViews(config, options)) try {
253
+ await run(view.sql);
254
+ results.push({
255
+ entityKey: view.entityKey,
256
+ ok: true,
257
+ sql: view.sql
258
+ });
259
+ } catch (error) {
260
+ results.push({
261
+ entityKey: view.entityKey,
262
+ ok: false,
263
+ sql: view.sql,
264
+ error: error instanceof Error ? error.message : String(error)
265
+ });
266
+ }
267
+ return results;
268
+ }
269
+ //#endregion
270
+ export { explainViewExecutionError as a, explainEntitiesConfig as i, compileEntityView as n, compileEntityViews as r, DEFAULT_VIEW_PREFIX as t };