clivly 0.9.4 → 0.9.6
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/README.md +30 -6
- package/core-entity-config.cjs +1 -1
- package/core-entity-config.mjs +1 -1
- package/core-mappings-config.cjs +1 -1
- package/core-mappings-config.mjs +1 -1
- package/core-sync-engine.cjs +1 -1
- package/core-sync-engine.mjs +1 -1
- package/core-view-compiler.cjs +1 -1
- package/core-view-compiler.mjs +1 -1
- package/core.cjs +13 -7
- package/core.d.cts +2 -2
- package/core.d.mts +2 -2
- package/core.mjs +8 -8
- package/drizzle.cjs +19 -2
- package/drizzle.d.cts +1 -1
- package/drizzle.d.mts +1 -1
- package/drizzle.mjs +19 -2
- package/{entity-config-B74SPOUZ.mjs → entity-config-CusbTOJ1.mjs} +1 -1
- package/{entity-config-7sGCk_nw.cjs → entity-config-DE30_RpU.cjs} +1 -1
- package/index.cjs +129 -11
- package/index.mjs +129 -11
- package/internal-BQ47iN6f.cjs +259 -0
- package/internal-ChufvXE6.mjs +212 -0
- package/{internal-o2M7FGlR.d.mts → internal-CvlFk3KC.d.mts} +82 -2
- package/{internal-wCXSELDN.d.cts → internal-DGUOLQAq.d.cts} +82 -2
- package/internal-entity-config.cjs +1 -1
- package/internal-entity-config.mjs +1 -1
- package/internal-entity-heuristics.cjs +1 -1
- package/internal-entity-heuristics.mjs +1 -1
- package/internal-mappings-config.cjs +1 -1
- package/internal-mappings-config.mjs +1 -1
- package/internal-sync-engine.cjs +1 -1
- package/internal-sync-engine.mjs +1 -1
- package/internal-view-compiler.cjs +1 -1
- package/internal-view-compiler.mjs +1 -1
- package/internal.cjs +13 -7
- package/internal.d.cts +2 -2
- package/internal.d.mts +2 -2
- package/internal.mjs +8 -8
- package/{mappings-config-DQS_s4dg.mjs → mappings-config-7qQ47p9K.mjs} +1 -1
- package/{mappings-config-CLyEg0sf.cjs → mappings-config-CjCmT-aU.cjs} +1 -1
- package/{namespace-probe-Cn7voAd0.d.mts → namespace-probe-Ckn9yJ6F.d.mts} +45 -2
- package/{namespace-probe-BoGI18dQ.d.cts → namespace-probe-DaTJINS-.d.cts} +45 -2
- package/package.json +1 -1
- package/{projection-Dz6_i1-w.cjs → projection-DEwL-D8R.cjs} +6 -0
- package/{projection-CSGJbZlZ.mjs → projection-hnPSgH6G.mjs} +1 -1
- package/{projection-sql-BgnfiGqF.mjs → projection-sql-9zzTVGvf.mjs} +34 -2
- package/{projection-sql-Cl1qIn8m.cjs → projection-sql-iHy-Cx6V.cjs} +45 -1
- package/{sdk-DYBDR939.mjs → sdk-Dkq6ELkf.mjs} +32 -8
- package/{sdk-CM1UO-I7.cjs → sdk-w8Kv_XnW.cjs} +32 -8
- package/sdk.cjs +2 -2
- package/sdk.d.cts +2 -2
- package/sdk.d.mts +2 -2
- package/sdk.mjs +2 -2
- package/{sync-engine-C4A5jgA4.cjs → sync-engine-BLgwLpR6.cjs} +1 -1
- package/{sync-engine-Dt5QQSKO.mjs → sync-engine-B_IzoUSx.mjs} +1 -1
- package/{view-compiler-B5nDal14.cjs → view-compiler-BHI5E4QI.cjs} +2 -2
- package/{view-compiler-CSYwEWf5.mjs → view-compiler-D_6gfXFr.mjs} +2 -2
- package/vite.d.cts +1 -1
- package/vite.d.mts +1 -1
- package/internal-CykXpEGk.mjs +0 -68
- package/internal-DqduZU3l.cjs +0 -97
package/index.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { t as CANONICAL_FIELDS } from "./entity-config-
|
|
2
|
+
import { t as CANONICAL_FIELDS } from "./entity-config-CusbTOJ1.mjs";
|
|
3
3
|
import "./mapping-score-C68uAqGB.mjs";
|
|
4
4
|
import { matchesConcept, requiredFieldsFor, scoreFieldMap, scoreRelationships, suggestCursorField, toFieldMap } from "./internal-entity-heuristics.mjs";
|
|
5
|
-
import { s as isPlaceholderUrl, t as createClivlySDK } from "./sdk-
|
|
5
|
+
import { s as isPlaceholderUrl, t as createClivlySDK } from "./sdk-Dkq6ELkf.mjs";
|
|
6
6
|
import { createRequire } from "node:module";
|
|
7
7
|
import { spawn } from "node:child_process";
|
|
8
8
|
import { accessSync, constants, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, watch, writeFileSync } from "node:fs";
|
|
@@ -973,7 +973,7 @@ function isKeySet(contents, key) {
|
|
|
973
973
|
function appendEnvKeys(existing, entries) {
|
|
974
974
|
const missing = entries.filter((e) => !hasKey$1(existing, e.key));
|
|
975
975
|
if (missing.length === 0) return existing;
|
|
976
|
-
const block = missing.map((e) => `${e.comment ? `# ${e.comment}\n` : ""}${e.key}
|
|
976
|
+
const block = missing.map((e) => `${e.comment ? `# ${e.comment}\n` : ""}${e.key}=${e.value ?? ""}`).join("\n");
|
|
977
977
|
return `${existing}${existing.length > 0 && !existing.endsWith("\n") ? "\n" : ""}${existing.length > 0 ? "\n" : ""}# Clivly\n${block}\n`;
|
|
978
978
|
}
|
|
979
979
|
/**
|
|
@@ -1595,7 +1595,7 @@ function planInit(schema, overrides = {}) {
|
|
|
1595
1595
|
todos.push(...candidateTodos);
|
|
1596
1596
|
company = candidate;
|
|
1597
1597
|
if (matchesConcept(companyTable.tableName, "contact")) todos.push(`"${companyTable.tableName}" matches both contact and company naming — assigned to company. Swap it to your contact entity if that's wrong.`);
|
|
1598
|
-
} else todos.push(`"${companyTable.tableName}" looked like a company but no fields could be mapped — left it out. Add it to \`entities\` once you know which columns map to name/domain.`);
|
|
1598
|
+
} else todos.push(`"${companyTable.tableName}" looked like a company but no fields could be mapped — left it out. Add it to \`entities\` once you know which columns map to name/domain, or build a derived object from it in Clivly (Integrations → Map as custom object) if the fields live in a joined table.`);
|
|
1599
1599
|
}
|
|
1600
1600
|
if (contactTable && contact) {
|
|
1601
1601
|
const scored = scoreRelationships(toScoredTable(contactTable), schema.tables.map(toScoredTable));
|
|
@@ -1809,7 +1809,8 @@ const CLIVLY_ENV_KEYS = [
|
|
|
1809
1809
|
},
|
|
1810
1810
|
{
|
|
1811
1811
|
key: "CLIVLY_SYNC_TRIGGER_URL",
|
|
1812
|
-
|
|
1812
|
+
value: TRIGGER_URL_PLACEHOLDER,
|
|
1813
|
+
comment: "Public URL of your tick route — replace <yourDomain.com> once deployed. Local dev: run `clivly dev` instead."
|
|
1813
1814
|
},
|
|
1814
1815
|
{
|
|
1815
1816
|
key: "CLIVLY_SYNC_TRIGGER_SECRET",
|
|
@@ -2740,6 +2741,103 @@ function ladderExitCode(results) {
|
|
|
2740
2741
|
return results.every((result) => result.state !== "fail" && result.state !== "pending") ? 0 : 1;
|
|
2741
2742
|
}
|
|
2742
2743
|
//#endregion
|
|
2744
|
+
//#region src/boot-hook-check.ts
|
|
2745
|
+
const REMIX_ENTRY_CANDIDATES = [
|
|
2746
|
+
"app/entry.server.tsx",
|
|
2747
|
+
"app/entry.server.ts",
|
|
2748
|
+
"app/entry.server.jsx",
|
|
2749
|
+
"app/entry.server.js"
|
|
2750
|
+
];
|
|
2751
|
+
const NEXT_CONFIG_CANDIDATES = [
|
|
2752
|
+
"next.config.ts",
|
|
2753
|
+
"next.config.mjs",
|
|
2754
|
+
"next.config.js",
|
|
2755
|
+
"next.config.cjs",
|
|
2756
|
+
"next.config.mts"
|
|
2757
|
+
];
|
|
2758
|
+
const CLIVLY_MENTION = /clivly/i;
|
|
2759
|
+
const INSTRUMENTATION_HOOK_ENABLED = /instrumentationHook\s*:\s*true/;
|
|
2760
|
+
/**
|
|
2761
|
+
* Where this framework's boot hook lives, most likely location first.
|
|
2762
|
+
*
|
|
2763
|
+
* The file-owning frameworks are asked of `boot-hook.ts` rather than restated,
|
|
2764
|
+
* so a path change there cannot leave this check hunting for a file `init` no
|
|
2765
|
+
* longer writes. The other two are patched into a file the app already owns.
|
|
2766
|
+
*/
|
|
2767
|
+
function bootSiteCandidates(framework) {
|
|
2768
|
+
const owned = [bootHookPath(framework, "app"), bootHookPath(framework, "src/app")].filter((path) => path !== null);
|
|
2769
|
+
if (owned.length > 0) return [...new Set(owned)];
|
|
2770
|
+
if (framework === "tanstack-start") return [...VITE_CONFIG_CANDIDATES];
|
|
2771
|
+
if (framework === "remix") return REMIX_ENTRY_CANDIDATES;
|
|
2772
|
+
return [];
|
|
2773
|
+
}
|
|
2774
|
+
function declaredDeps(readFile) {
|
|
2775
|
+
const raw = readFile("package.json");
|
|
2776
|
+
if (raw === null) return null;
|
|
2777
|
+
try {
|
|
2778
|
+
const pkg = JSON.parse(raw);
|
|
2779
|
+
return {
|
|
2780
|
+
...pkg.dependencies,
|
|
2781
|
+
...pkg.devDependencies
|
|
2782
|
+
};
|
|
2783
|
+
} catch {
|
|
2784
|
+
return null;
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
function findBootSite(readFile, framework) {
|
|
2788
|
+
for (const candidate of bootSiteCandidates(framework)) {
|
|
2789
|
+
const source = readFile(candidate);
|
|
2790
|
+
if (source !== null && CLIVLY_MENTION.test(source)) return candidate;
|
|
2791
|
+
}
|
|
2792
|
+
return null;
|
|
2793
|
+
}
|
|
2794
|
+
/**
|
|
2795
|
+
* On Next.js below 15 the hook file is ignored unless the flag is on, so a
|
|
2796
|
+
* present-and-correct instrumentation.ts is not evidence of anything. A missing
|
|
2797
|
+
* next.config is the same failure as one without the flag: either way nothing
|
|
2798
|
+
* switches the hook on.
|
|
2799
|
+
*/
|
|
2800
|
+
function nextFlagVerdict(readFile, version, site) {
|
|
2801
|
+
if (!nextNeedsInstrumentationFlag(version)) return null;
|
|
2802
|
+
for (const candidate of NEXT_CONFIG_CANDIDATES) {
|
|
2803
|
+
const source = readFile(candidate);
|
|
2804
|
+
if (source !== null && INSTRUMENTATION_HOOK_ENABLED.test(source)) return null;
|
|
2805
|
+
}
|
|
2806
|
+
return {
|
|
2807
|
+
state: "broken",
|
|
2808
|
+
detail: `${site} exists, but Next.js ${version} ignores it — experimental.instrumentationHook is not enabled in next.config.`,
|
|
2809
|
+
hint: NEXT_INSTRUMENTATION_FLAG_STEP
|
|
2810
|
+
};
|
|
2811
|
+
}
|
|
2812
|
+
function checkBootHook(readFile) {
|
|
2813
|
+
const deps = declaredDeps(readFile);
|
|
2814
|
+
if (deps === null) return {
|
|
2815
|
+
state: "unknown",
|
|
2816
|
+
detail: "package.json not readable — cannot tell how this app boots."
|
|
2817
|
+
};
|
|
2818
|
+
const framework = detectFramework(deps);
|
|
2819
|
+
if (framework === "unknown") return {
|
|
2820
|
+
state: "unknown",
|
|
2821
|
+
detail: "No supported framework detected, so there is no known place to check."
|
|
2822
|
+
};
|
|
2823
|
+
const label = FRAMEWORK_LABELS[framework];
|
|
2824
|
+
const site = findBootSite(readFile, framework);
|
|
2825
|
+
if (site === null) return {
|
|
2826
|
+
state: "broken",
|
|
2827
|
+
detail: `No Clivly boot hook found in this ${label} app.`,
|
|
2828
|
+
hint: `Nothing starts the SDK, so your app never tells Clivly it is running — the dashboard stays silent with no error anywhere. Run \`clivly init\` to scaffold it, or call \`clivly.start()\` where your server boots (${bootSiteCandidates(framework)[0]}).`
|
|
2829
|
+
};
|
|
2830
|
+
const nextVersion = deps.next;
|
|
2831
|
+
if (framework === "nextjs" && nextVersion) {
|
|
2832
|
+
const broken = nextFlagVerdict(readFile, nextVersion, site);
|
|
2833
|
+
if (broken) return broken;
|
|
2834
|
+
}
|
|
2835
|
+
return {
|
|
2836
|
+
state: "ok",
|
|
2837
|
+
detail: site
|
|
2838
|
+
};
|
|
2839
|
+
}
|
|
2840
|
+
//#endregion
|
|
2743
2841
|
//#region src/edge-safety.ts
|
|
2744
2842
|
const NODE_BUILTINS = /* @__PURE__ */ new Set([
|
|
2745
2843
|
"assert",
|
|
@@ -2926,7 +3024,7 @@ function pingOnce(ctx) {
|
|
|
2926
3024
|
}
|
|
2927
3025
|
const REASON_HINT = {
|
|
2928
3026
|
ok: "",
|
|
2929
|
-
invalid_api_key: "The key was rejected (401).
|
|
3027
|
+
invalid_api_key: "The key was rejected (401). Run `clivly login` to re-pair and rewrite the credentials, and check CLIVLY_SECRET_KEY (or legacy CLIVLY_API_KEY) belongs to the org whose dashboard you are watching.",
|
|
2930
3028
|
forbidden: "The key is valid but lacks access to this org (403).",
|
|
2931
3029
|
rate_limited: "Rate limited (429). Wait a moment and retry.",
|
|
2932
3030
|
server_error: "Clivly returned a server error (5xx). Retry shortly.",
|
|
@@ -3025,15 +3123,16 @@ const triggerUrlStage = {
|
|
|
3025
3123
|
label: "Sync trigger URL",
|
|
3026
3124
|
needs: [configStage.id],
|
|
3027
3125
|
run(ctx) {
|
|
3028
|
-
const
|
|
3029
|
-
if (
|
|
3126
|
+
const trigger = ctx.sdk?.describeSyncTrigger?.();
|
|
3127
|
+
if (trigger?.resolved) return Promise.resolve({
|
|
3030
3128
|
state: "pass",
|
|
3031
|
-
detail:
|
|
3129
|
+
detail: trigger.resolved
|
|
3032
3130
|
});
|
|
3033
|
-
const
|
|
3131
|
+
const configured = trigger?.configured;
|
|
3132
|
+
const isPlaceholder = configured !== void 0 && isPlaceholderUrl(configured);
|
|
3034
3133
|
return Promise.resolve({
|
|
3035
3134
|
state: "warn",
|
|
3036
|
-
detail: isPlaceholder ? `Still the placeholder (${
|
|
3135
|
+
detail: isPlaceholder ? `Still the scaffolded placeholder (${configured}) — remote sync cannot call back into this app.` : "Not set — remote sync cannot call back into this app.",
|
|
3037
3136
|
hint: "Replace <yourDomain.com> with your deployed origin, or delete the line — on Vercel, Cloudflare Pages, Railway, Render and Fly the SDK derives the URL from the platform. For local development run `clivly dev`: it opens a tunnel and registers the URL for you, outranking whatever is in .env. Push-only setups can ignore this."
|
|
3038
3137
|
});
|
|
3039
3138
|
}
|
|
@@ -3082,6 +3181,24 @@ const STATUS_STAGES = [
|
|
|
3082
3181
|
});
|
|
3083
3182
|
}
|
|
3084
3183
|
},
|
|
3184
|
+
{
|
|
3185
|
+
id: "boot-hook",
|
|
3186
|
+
label: "Boot hook wired",
|
|
3187
|
+
run(ctx) {
|
|
3188
|
+
const read = ctx.readConfigSource ?? defaultReadConfigSource;
|
|
3189
|
+
const root = dirname(ctx.configPath);
|
|
3190
|
+
const verdict = checkBootHook((relative) => read(resolve(root, relative)));
|
|
3191
|
+
if (verdict.state === "ok") return Promise.resolve({
|
|
3192
|
+
state: "pass",
|
|
3193
|
+
detail: `starts Clivly from ${verdict.detail}`
|
|
3194
|
+
});
|
|
3195
|
+
return Promise.resolve({
|
|
3196
|
+
state: verdict.state === "unknown" ? "skip" : "fail",
|
|
3197
|
+
detail: verdict.detail,
|
|
3198
|
+
hint: verdict.hint
|
|
3199
|
+
});
|
|
3200
|
+
}
|
|
3201
|
+
},
|
|
3085
3202
|
sdkChecksStage,
|
|
3086
3203
|
triggerUrlStage
|
|
3087
3204
|
];
|
|
@@ -3135,6 +3252,7 @@ const ALIAS_STAGE_IDS = {
|
|
|
3135
3252
|
doctor: [
|
|
3136
3253
|
"config",
|
|
3137
3254
|
"runtime",
|
|
3255
|
+
"boot-hook",
|
|
3138
3256
|
"setup-checks"
|
|
3139
3257
|
],
|
|
3140
3258
|
connect: [
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
const require_projection = require("./projection-DEwL-D8R.cjs");
|
|
2
|
+
const require_entity_config = require("./entity-config-DE30_RpU.cjs");
|
|
3
|
+
//#region ../core/src/projection-draft.ts
|
|
4
|
+
function emptyProjectionDraft(baseTable) {
|
|
5
|
+
return {
|
|
6
|
+
baseTable,
|
|
7
|
+
entityKey: "",
|
|
8
|
+
fieldBindings: {},
|
|
9
|
+
identity: { kind: "base_pk" },
|
|
10
|
+
joins: []
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The ON conditions implied by a foreign key between the two tables, so the
|
|
15
|
+
* builder can pre-fill a join instead of making the user hand-pick columns.
|
|
16
|
+
* Returns [] when no FK relates them — the user then picks manually.
|
|
17
|
+
*/
|
|
18
|
+
function suggestJoinConditions(baseTable, joinTable, schema, joinKey) {
|
|
19
|
+
const join = schema.find((t) => t.name === joinTable);
|
|
20
|
+
const conditions = [];
|
|
21
|
+
for (const column of join?.columnsMeta ?? []) if (column.references?.table === baseTable) conditions.push({
|
|
22
|
+
left: {
|
|
23
|
+
table: joinKey,
|
|
24
|
+
column: column.name
|
|
25
|
+
},
|
|
26
|
+
op: "eq",
|
|
27
|
+
right: {
|
|
28
|
+
table: require_projection.BASE_REF,
|
|
29
|
+
column: column.references.column
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
return conditions;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Add a join on `joinTable`, keyed and pre-filled.
|
|
36
|
+
*
|
|
37
|
+
* The key is the table's own name where that is free, because it is what the
|
|
38
|
+
* user already calls the table; a collision falls back to `_2`, `_3` and so on.
|
|
39
|
+
* Uniqueness is not cosmetic — the key is the SQL alias and the name every ref
|
|
40
|
+
* addresses the table by, so two joins sharing one would make every ref
|
|
41
|
+
* ambiguous. `base` is reserved for the base table and can never be taken.
|
|
42
|
+
*/
|
|
43
|
+
function addDraftJoin(draft, joinTable, schema) {
|
|
44
|
+
const taken = /* @__PURE__ */ new Set([require_projection.BASE_REF, ...draft.joins.map((j) => j.key)]);
|
|
45
|
+
let key = joinTable;
|
|
46
|
+
let suffix = 2;
|
|
47
|
+
while (taken.has(key)) {
|
|
48
|
+
key = `${joinTable}_${suffix}`;
|
|
49
|
+
suffix += 1;
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
...draft,
|
|
53
|
+
joins: [...draft.joins, {
|
|
54
|
+
key,
|
|
55
|
+
table: joinTable,
|
|
56
|
+
type: "left",
|
|
57
|
+
on: suggestJoinConditions(draft.baseTable, joinTable, schema, key)
|
|
58
|
+
}]
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Remove a join, and everything that addressed it.
|
|
63
|
+
*
|
|
64
|
+
* Refs to a removed join are refs to a table the projection no longer declares.
|
|
65
|
+
* Left in place they fail validation as "unknown ref table", which blames the
|
|
66
|
+
* field — or, worse, the identity — for the join's removal. Identity falls back
|
|
67
|
+
* to the base primary key, the same default a new draft starts from.
|
|
68
|
+
*/
|
|
69
|
+
function removeDraftJoin(draft, joinKey) {
|
|
70
|
+
const fieldBindings = Object.fromEntries(Object.entries(draft.fieldBindings).filter(([, binding]) => binding.from.table !== joinKey));
|
|
71
|
+
const identityDependsOnJoin = require_projection.identityRefs(draft.identity, "").some((ref) => ref.table === joinKey);
|
|
72
|
+
return {
|
|
73
|
+
...draft,
|
|
74
|
+
joins: draft.joins.filter((join) => join.key !== joinKey),
|
|
75
|
+
fieldBindings,
|
|
76
|
+
identity: identityDependsOnJoin ? { kind: "base_pk" } : draft.identity
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Every table a field or identity may currently read from, in ref order: the
|
|
81
|
+
* base table first, then each join.
|
|
82
|
+
*
|
|
83
|
+
* Returns [] when the base table is not in the discovered schema — there is
|
|
84
|
+
* nothing truthful to offer, and inventing columns would let someone build a
|
|
85
|
+
* projection that cannot compile.
|
|
86
|
+
*/
|
|
87
|
+
function draftRefOptions(draft, schema) {
|
|
88
|
+
const byName = new Map(schema.map((table) => [table.name, table]));
|
|
89
|
+
const base = byName.get(draft.baseTable);
|
|
90
|
+
if (!base) return [];
|
|
91
|
+
const options = [{
|
|
92
|
+
ref: require_projection.BASE_REF,
|
|
93
|
+
label: `${base.name} (${require_projection.BASE_REF})`,
|
|
94
|
+
columns: base.columns
|
|
95
|
+
}];
|
|
96
|
+
for (const join of draft.joins) {
|
|
97
|
+
const table = byName.get(join.table);
|
|
98
|
+
if (table) options.push({
|
|
99
|
+
ref: join.key,
|
|
100
|
+
label: `${table.name} (${join.key})`,
|
|
101
|
+
columns: table.columns
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return options;
|
|
105
|
+
}
|
|
106
|
+
/** A draft becomes a projection once it has a valid key and ≥1 binding. */
|
|
107
|
+
function draftToProjection(draft) {
|
|
108
|
+
if (!require_projection.PROJECTION_KEY_PATTERN.test(draft.entityKey)) return null;
|
|
109
|
+
if (Object.keys(draft.fieldBindings).length === 0) return null;
|
|
110
|
+
return {
|
|
111
|
+
version: 1,
|
|
112
|
+
kind: "projection",
|
|
113
|
+
entityKey: draft.entityKey,
|
|
114
|
+
baseTable: draft.baseTable,
|
|
115
|
+
joins: draft.joins,
|
|
116
|
+
fieldBindings: draft.fieldBindings,
|
|
117
|
+
identity: draft.identity
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
const PROBE_KEY = "draft";
|
|
121
|
+
/**
|
|
122
|
+
* A draft rendered as a projection so the semantic checks can run against it
|
|
123
|
+
* while it is being written, not only once it is finished.
|
|
124
|
+
*
|
|
125
|
+
* `entityKey` is stood in for rather than taken from the draft: it is the one
|
|
126
|
+
* field `validateProjection` never looks at, and a half-typed key would
|
|
127
|
+
* otherwise have to be valid before any join or identity could be checked. The
|
|
128
|
+
* missing or malformed key is reported separately, by `draftErrors`.
|
|
129
|
+
*/
|
|
130
|
+
function probeProjection(draft) {
|
|
131
|
+
return {
|
|
132
|
+
version: 1,
|
|
133
|
+
kind: "projection",
|
|
134
|
+
entityKey: PROBE_KEY,
|
|
135
|
+
baseTable: draft.baseTable,
|
|
136
|
+
joins: draft.joins,
|
|
137
|
+
fieldBindings: draft.fieldBindings,
|
|
138
|
+
identity: draft.identity
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* `validateProjection` appends its own `projection.` segment to the base path it
|
|
143
|
+
* is given, because in a config the base is the entity that owns the projection
|
|
144
|
+
* (`entities.accounts` → `entities.accounts.projection.identity`). A draft has
|
|
145
|
+
* no owning entity, so passing "projection" as the base produced
|
|
146
|
+
* `projection.projection.identity` — a path no caller could match against, and
|
|
147
|
+
* the reason `draftErrors` was only ever consumed as a flat list of messages.
|
|
148
|
+
*
|
|
149
|
+
* Passing an empty base and dropping the leading separator leaves one
|
|
150
|
+
* convention: every path here starts `projection.` exactly once, whether it came
|
|
151
|
+
* from this module or from the validator.
|
|
152
|
+
*/
|
|
153
|
+
function semanticErrors(draft, schema) {
|
|
154
|
+
return require_entity_config.validateProjection(probeProjection(draft), schema, "").map((error) => ({
|
|
155
|
+
...error,
|
|
156
|
+
path: error.path.startsWith(".") ? error.path.slice(1) : error.path
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Every reason this draft cannot be saved, one per cause and each anchored to
|
|
161
|
+
* the section that owns it.
|
|
162
|
+
*
|
|
163
|
+
* One lumped "Name the object and map a field." was all this used to return for
|
|
164
|
+
* an incomplete draft, at the bare path "projection". Two consequences, both
|
|
165
|
+
* bad: a builder had nowhere to attach the message, and — because it returned
|
|
166
|
+
* before validating — a fanning join under a `base_pk` identity stayed silent
|
|
167
|
+
* until the draft was otherwise complete, which is well after the point where
|
|
168
|
+
* the user chose the join.
|
|
169
|
+
*/
|
|
170
|
+
function draftErrors(draft, schema) {
|
|
171
|
+
const errors = [];
|
|
172
|
+
if (draft.entityKey === "") errors.push({
|
|
173
|
+
path: "projection.entityKey",
|
|
174
|
+
message: "Key is required."
|
|
175
|
+
});
|
|
176
|
+
else if (!require_projection.PROJECTION_KEY_PATTERN.test(draft.entityKey)) errors.push({
|
|
177
|
+
path: "projection.entityKey",
|
|
178
|
+
message: `Key ${require_projection.PROJECTION_KEY_MESSAGE}.`
|
|
179
|
+
});
|
|
180
|
+
if (Object.keys(draft.fieldBindings).length === 0) errors.push({
|
|
181
|
+
path: "projection.fieldBindings",
|
|
182
|
+
message: "Map at least one field."
|
|
183
|
+
});
|
|
184
|
+
if (draft.baseTable === "") {
|
|
185
|
+
errors.push({
|
|
186
|
+
path: "projection.baseTable",
|
|
187
|
+
message: "Pick a base table."
|
|
188
|
+
});
|
|
189
|
+
return errors;
|
|
190
|
+
}
|
|
191
|
+
errors.push(...semanticErrors(draft, schema));
|
|
192
|
+
return errors;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Per-join fan-out, for the builder's inline cardinality hints.
|
|
196
|
+
*
|
|
197
|
+
* Answered from the joins alone. Joins are chosen before the key and the fields
|
|
198
|
+
* are filled in, and a fan-out warning is precisely what should steer the
|
|
199
|
+
* identity choice while it is being made — requiring a complete draft put the
|
|
200
|
+
* hint after the decision it exists to inform.
|
|
201
|
+
*/
|
|
202
|
+
function describeCardinality(draft, schema) {
|
|
203
|
+
const projection = probeProjection(draft);
|
|
204
|
+
const tablesByName = new Map(schema.map((t) => [t.name, t]));
|
|
205
|
+
const fanning = new Set(require_entity_config.fanOutJoins(projection, tablesByName).map((j) => j.key));
|
|
206
|
+
return projection.joins.map((join) => ({
|
|
207
|
+
joinKey: join.key,
|
|
208
|
+
fansOut: fanning.has(join.key)
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
//#endregion
|
|
212
|
+
Object.defineProperty(exports, "addDraftJoin", {
|
|
213
|
+
enumerable: true,
|
|
214
|
+
get: function() {
|
|
215
|
+
return addDraftJoin;
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
Object.defineProperty(exports, "describeCardinality", {
|
|
219
|
+
enumerable: true,
|
|
220
|
+
get: function() {
|
|
221
|
+
return describeCardinality;
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
Object.defineProperty(exports, "draftErrors", {
|
|
225
|
+
enumerable: true,
|
|
226
|
+
get: function() {
|
|
227
|
+
return draftErrors;
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
Object.defineProperty(exports, "draftRefOptions", {
|
|
231
|
+
enumerable: true,
|
|
232
|
+
get: function() {
|
|
233
|
+
return draftRefOptions;
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
Object.defineProperty(exports, "draftToProjection", {
|
|
237
|
+
enumerable: true,
|
|
238
|
+
get: function() {
|
|
239
|
+
return draftToProjection;
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
Object.defineProperty(exports, "emptyProjectionDraft", {
|
|
243
|
+
enumerable: true,
|
|
244
|
+
get: function() {
|
|
245
|
+
return emptyProjectionDraft;
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
Object.defineProperty(exports, "removeDraftJoin", {
|
|
249
|
+
enumerable: true,
|
|
250
|
+
get: function() {
|
|
251
|
+
return removeDraftJoin;
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
Object.defineProperty(exports, "suggestJoinConditions", {
|
|
255
|
+
enumerable: true,
|
|
256
|
+
get: function() {
|
|
257
|
+
return suggestJoinConditions;
|
|
258
|
+
}
|
|
259
|
+
});
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { i as identityRefs, n as PROJECTION_KEY_MESSAGE, r as PROJECTION_KEY_PATTERN, t as BASE_REF } from "./projection-hnPSgH6G.mjs";
|
|
2
|
+
import { h as fanOutJoins, m as validateProjection } from "./entity-config-CusbTOJ1.mjs";
|
|
3
|
+
//#region ../core/src/projection-draft.ts
|
|
4
|
+
function emptyProjectionDraft(baseTable) {
|
|
5
|
+
return {
|
|
6
|
+
baseTable,
|
|
7
|
+
entityKey: "",
|
|
8
|
+
fieldBindings: {},
|
|
9
|
+
identity: { kind: "base_pk" },
|
|
10
|
+
joins: []
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The ON conditions implied by a foreign key between the two tables, so the
|
|
15
|
+
* builder can pre-fill a join instead of making the user hand-pick columns.
|
|
16
|
+
* Returns [] when no FK relates them — the user then picks manually.
|
|
17
|
+
*/
|
|
18
|
+
function suggestJoinConditions(baseTable, joinTable, schema, joinKey) {
|
|
19
|
+
const join = schema.find((t) => t.name === joinTable);
|
|
20
|
+
const conditions = [];
|
|
21
|
+
for (const column of join?.columnsMeta ?? []) if (column.references?.table === baseTable) conditions.push({
|
|
22
|
+
left: {
|
|
23
|
+
table: joinKey,
|
|
24
|
+
column: column.name
|
|
25
|
+
},
|
|
26
|
+
op: "eq",
|
|
27
|
+
right: {
|
|
28
|
+
table: BASE_REF,
|
|
29
|
+
column: column.references.column
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
return conditions;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Add a join on `joinTable`, keyed and pre-filled.
|
|
36
|
+
*
|
|
37
|
+
* The key is the table's own name where that is free, because it is what the
|
|
38
|
+
* user already calls the table; a collision falls back to `_2`, `_3` and so on.
|
|
39
|
+
* Uniqueness is not cosmetic — the key is the SQL alias and the name every ref
|
|
40
|
+
* addresses the table by, so two joins sharing one would make every ref
|
|
41
|
+
* ambiguous. `base` is reserved for the base table and can never be taken.
|
|
42
|
+
*/
|
|
43
|
+
function addDraftJoin(draft, joinTable, schema) {
|
|
44
|
+
const taken = /* @__PURE__ */ new Set([BASE_REF, ...draft.joins.map((j) => j.key)]);
|
|
45
|
+
let key = joinTable;
|
|
46
|
+
let suffix = 2;
|
|
47
|
+
while (taken.has(key)) {
|
|
48
|
+
key = `${joinTable}_${suffix}`;
|
|
49
|
+
suffix += 1;
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
...draft,
|
|
53
|
+
joins: [...draft.joins, {
|
|
54
|
+
key,
|
|
55
|
+
table: joinTable,
|
|
56
|
+
type: "left",
|
|
57
|
+
on: suggestJoinConditions(draft.baseTable, joinTable, schema, key)
|
|
58
|
+
}]
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Remove a join, and everything that addressed it.
|
|
63
|
+
*
|
|
64
|
+
* Refs to a removed join are refs to a table the projection no longer declares.
|
|
65
|
+
* Left in place they fail validation as "unknown ref table", which blames the
|
|
66
|
+
* field — or, worse, the identity — for the join's removal. Identity falls back
|
|
67
|
+
* to the base primary key, the same default a new draft starts from.
|
|
68
|
+
*/
|
|
69
|
+
function removeDraftJoin(draft, joinKey) {
|
|
70
|
+
const fieldBindings = Object.fromEntries(Object.entries(draft.fieldBindings).filter(([, binding]) => binding.from.table !== joinKey));
|
|
71
|
+
const identityDependsOnJoin = identityRefs(draft.identity, "").some((ref) => ref.table === joinKey);
|
|
72
|
+
return {
|
|
73
|
+
...draft,
|
|
74
|
+
joins: draft.joins.filter((join) => join.key !== joinKey),
|
|
75
|
+
fieldBindings,
|
|
76
|
+
identity: identityDependsOnJoin ? { kind: "base_pk" } : draft.identity
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Every table a field or identity may currently read from, in ref order: the
|
|
81
|
+
* base table first, then each join.
|
|
82
|
+
*
|
|
83
|
+
* Returns [] when the base table is not in the discovered schema — there is
|
|
84
|
+
* nothing truthful to offer, and inventing columns would let someone build a
|
|
85
|
+
* projection that cannot compile.
|
|
86
|
+
*/
|
|
87
|
+
function draftRefOptions(draft, schema) {
|
|
88
|
+
const byName = new Map(schema.map((table) => [table.name, table]));
|
|
89
|
+
const base = byName.get(draft.baseTable);
|
|
90
|
+
if (!base) return [];
|
|
91
|
+
const options = [{
|
|
92
|
+
ref: BASE_REF,
|
|
93
|
+
label: `${base.name} (${BASE_REF})`,
|
|
94
|
+
columns: base.columns
|
|
95
|
+
}];
|
|
96
|
+
for (const join of draft.joins) {
|
|
97
|
+
const table = byName.get(join.table);
|
|
98
|
+
if (table) options.push({
|
|
99
|
+
ref: join.key,
|
|
100
|
+
label: `${table.name} (${join.key})`,
|
|
101
|
+
columns: table.columns
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return options;
|
|
105
|
+
}
|
|
106
|
+
/** A draft becomes a projection once it has a valid key and ≥1 binding. */
|
|
107
|
+
function draftToProjection(draft) {
|
|
108
|
+
if (!PROJECTION_KEY_PATTERN.test(draft.entityKey)) return null;
|
|
109
|
+
if (Object.keys(draft.fieldBindings).length === 0) return null;
|
|
110
|
+
return {
|
|
111
|
+
version: 1,
|
|
112
|
+
kind: "projection",
|
|
113
|
+
entityKey: draft.entityKey,
|
|
114
|
+
baseTable: draft.baseTable,
|
|
115
|
+
joins: draft.joins,
|
|
116
|
+
fieldBindings: draft.fieldBindings,
|
|
117
|
+
identity: draft.identity
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
const PROBE_KEY = "draft";
|
|
121
|
+
/**
|
|
122
|
+
* A draft rendered as a projection so the semantic checks can run against it
|
|
123
|
+
* while it is being written, not only once it is finished.
|
|
124
|
+
*
|
|
125
|
+
* `entityKey` is stood in for rather than taken from the draft: it is the one
|
|
126
|
+
* field `validateProjection` never looks at, and a half-typed key would
|
|
127
|
+
* otherwise have to be valid before any join or identity could be checked. The
|
|
128
|
+
* missing or malformed key is reported separately, by `draftErrors`.
|
|
129
|
+
*/
|
|
130
|
+
function probeProjection(draft) {
|
|
131
|
+
return {
|
|
132
|
+
version: 1,
|
|
133
|
+
kind: "projection",
|
|
134
|
+
entityKey: PROBE_KEY,
|
|
135
|
+
baseTable: draft.baseTable,
|
|
136
|
+
joins: draft.joins,
|
|
137
|
+
fieldBindings: draft.fieldBindings,
|
|
138
|
+
identity: draft.identity
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* `validateProjection` appends its own `projection.` segment to the base path it
|
|
143
|
+
* is given, because in a config the base is the entity that owns the projection
|
|
144
|
+
* (`entities.accounts` → `entities.accounts.projection.identity`). A draft has
|
|
145
|
+
* no owning entity, so passing "projection" as the base produced
|
|
146
|
+
* `projection.projection.identity` — a path no caller could match against, and
|
|
147
|
+
* the reason `draftErrors` was only ever consumed as a flat list of messages.
|
|
148
|
+
*
|
|
149
|
+
* Passing an empty base and dropping the leading separator leaves one
|
|
150
|
+
* convention: every path here starts `projection.` exactly once, whether it came
|
|
151
|
+
* from this module or from the validator.
|
|
152
|
+
*/
|
|
153
|
+
function semanticErrors(draft, schema) {
|
|
154
|
+
return validateProjection(probeProjection(draft), schema, "").map((error) => ({
|
|
155
|
+
...error,
|
|
156
|
+
path: error.path.startsWith(".") ? error.path.slice(1) : error.path
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Every reason this draft cannot be saved, one per cause and each anchored to
|
|
161
|
+
* the section that owns it.
|
|
162
|
+
*
|
|
163
|
+
* One lumped "Name the object and map a field." was all this used to return for
|
|
164
|
+
* an incomplete draft, at the bare path "projection". Two consequences, both
|
|
165
|
+
* bad: a builder had nowhere to attach the message, and — because it returned
|
|
166
|
+
* before validating — a fanning join under a `base_pk` identity stayed silent
|
|
167
|
+
* until the draft was otherwise complete, which is well after the point where
|
|
168
|
+
* the user chose the join.
|
|
169
|
+
*/
|
|
170
|
+
function draftErrors(draft, schema) {
|
|
171
|
+
const errors = [];
|
|
172
|
+
if (draft.entityKey === "") errors.push({
|
|
173
|
+
path: "projection.entityKey",
|
|
174
|
+
message: "Key is required."
|
|
175
|
+
});
|
|
176
|
+
else if (!PROJECTION_KEY_PATTERN.test(draft.entityKey)) errors.push({
|
|
177
|
+
path: "projection.entityKey",
|
|
178
|
+
message: `Key ${PROJECTION_KEY_MESSAGE}.`
|
|
179
|
+
});
|
|
180
|
+
if (Object.keys(draft.fieldBindings).length === 0) errors.push({
|
|
181
|
+
path: "projection.fieldBindings",
|
|
182
|
+
message: "Map at least one field."
|
|
183
|
+
});
|
|
184
|
+
if (draft.baseTable === "") {
|
|
185
|
+
errors.push({
|
|
186
|
+
path: "projection.baseTable",
|
|
187
|
+
message: "Pick a base table."
|
|
188
|
+
});
|
|
189
|
+
return errors;
|
|
190
|
+
}
|
|
191
|
+
errors.push(...semanticErrors(draft, schema));
|
|
192
|
+
return errors;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Per-join fan-out, for the builder's inline cardinality hints.
|
|
196
|
+
*
|
|
197
|
+
* Answered from the joins alone. Joins are chosen before the key and the fields
|
|
198
|
+
* are filled in, and a fan-out warning is precisely what should steer the
|
|
199
|
+
* identity choice while it is being made — requiring a complete draft put the
|
|
200
|
+
* hint after the decision it exists to inform.
|
|
201
|
+
*/
|
|
202
|
+
function describeCardinality(draft, schema) {
|
|
203
|
+
const projection = probeProjection(draft);
|
|
204
|
+
const tablesByName = new Map(schema.map((t) => [t.name, t]));
|
|
205
|
+
const fanning = new Set(fanOutJoins(projection, tablesByName).map((j) => j.key));
|
|
206
|
+
return projection.joins.map((join) => ({
|
|
207
|
+
joinKey: join.key,
|
|
208
|
+
fansOut: fanning.has(join.key)
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
//#endregion
|
|
212
|
+
export { draftToProjection as a, suggestJoinConditions as c, draftRefOptions as i, describeCardinality as n, emptyProjectionDraft as o, draftErrors as r, removeDraftJoin as s, addDraftJoin as t };
|