@clivly/core 0.1.0 → 0.3.0-next.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.
- package/README.md +57 -0
- package/dist/adapter.cjs +0 -0
- package/dist/adapter.d.cts +46 -0
- package/dist/adapter.d.mts +16 -3
- package/dist/auth-adapter.cjs +7 -0
- package/dist/auth-adapter.d.cts +30 -0
- package/dist/drizzle.cjs +32 -0
- package/dist/drizzle.d.cts +21 -0
- package/dist/drizzle.d.mts +21 -0
- package/dist/drizzle.mjs +31 -0
- package/dist/entity-config.cjs +350 -0
- package/dist/entity-config.d.cts +296 -0
- package/dist/entity-config.d.mts +46 -8
- package/dist/entity-config.mjs +86 -13
- package/dist/entity-heuristics.cjs +442 -0
- package/dist/entity-heuristics.d.cts +68 -0
- package/dist/entity-heuristics.d.mts +68 -0
- package/dist/entity-heuristics.mjs +431 -0
- package/dist/index.cjs +27 -0
- package/dist/index.d.cts +9 -0
- package/dist/index.d.mts +4 -4
- package/dist/index.mjs +3 -3
- package/dist/mapping-form.cjs +50 -0
- package/dist/mapping-form.d.cts +35 -0
- package/dist/mapping-score.cjs +97 -0
- package/dist/mapping-score.d.cts +49 -0
- package/dist/mapping-score.d.mts +49 -0
- package/dist/mapping-score.mjs +87 -0
- package/dist/mappings-config.cjs +100 -0
- package/dist/mappings-config.d.cts +42 -0
- package/dist/mappings-config.d.mts +1 -0
- package/dist/mappings-config.mjs +21 -1
- package/dist/sync-engine.cjs +211 -0
- package/dist/sync-engine.d.cts +183 -0
- package/dist/sync-engine.d.mts +54 -9
- package/dist/sync-engine.mjs +91 -22
- package/dist/types.cjs +0 -0
- package/dist/types.d.cts +159 -0
- package/dist/types.d.mts +12 -1
- package/dist/view-compiler.cjs +188 -0
- package/dist/view-compiler.d.cts +71 -0
- package/dist/view-compiler.d.mts +29 -1
- package/dist/view-compiler.mjs +31 -1
- package/package.json +116 -23
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
//#region src/mapping-score.d.ts
|
|
2
|
+
type Tier = "High" | "Medium" | "Low";
|
|
3
|
+
interface Scored<T> {
|
|
4
|
+
/** Human-readable chips, e.g. `type text`, `name matches "contacts"`. */
|
|
5
|
+
reasons: string[];
|
|
6
|
+
/** Internal — used for ranking and the apply threshold. Never rendered. */
|
|
7
|
+
score: number;
|
|
8
|
+
tier: Tier;
|
|
9
|
+
value: T;
|
|
10
|
+
}
|
|
11
|
+
/** Tier cut-offs: score >= high → High; >= medium → Medium; else Low. */
|
|
12
|
+
interface Thresholds {
|
|
13
|
+
high: number;
|
|
14
|
+
medium: number;
|
|
15
|
+
}
|
|
16
|
+
declare const CONCEPT_TIERS: Thresholds;
|
|
17
|
+
declare const FIELD_TIERS: Thresholds;
|
|
18
|
+
declare const RELATIONSHIP_TIERS: Thresholds;
|
|
19
|
+
/** Two concept candidates closer than this are treated as ambiguous. */
|
|
20
|
+
declare const AMBIGUITY_MARGIN = 15;
|
|
21
|
+
/** Signal weights. Tuned against fixtures; adjust here, not at call sites. */
|
|
22
|
+
declare const W: {
|
|
23
|
+
readonly nameExact: 50;
|
|
24
|
+
readonly nameSubstring: 25;
|
|
25
|
+
readonly definingColumn: 20;
|
|
26
|
+
readonly hasPrimaryKey: 10;
|
|
27
|
+
readonly hasRows: 10;
|
|
28
|
+
readonly emptyTable: -20;
|
|
29
|
+
readonly fieldNameMatch: 50;
|
|
30
|
+
readonly typeCompatible: 30;
|
|
31
|
+
readonly typeMismatch: -30;
|
|
32
|
+
readonly requiredNotNull: 10;
|
|
33
|
+
readonly fkResolved: 60;
|
|
34
|
+
readonly fkNameHint: 30;
|
|
35
|
+
readonly joinTable: 20;
|
|
36
|
+
};
|
|
37
|
+
declare function tierFor(score: number, t: Thresholds): Tier;
|
|
38
|
+
declare function isTextLike(type: string | undefined): boolean;
|
|
39
|
+
declare function isNumericLike(type: string | undefined): boolean;
|
|
40
|
+
declare function isTimestampLike(type: string | undefined): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* True when the top two candidates are too close to call. Callers must never
|
|
43
|
+
* one-click apply an ambiguous result — the user picks.
|
|
44
|
+
*/
|
|
45
|
+
declare function isAmbiguous(ranked: Array<{
|
|
46
|
+
score: number;
|
|
47
|
+
}>): boolean;
|
|
48
|
+
//#endregion
|
|
49
|
+
export { AMBIGUITY_MARGIN, CONCEPT_TIERS, FIELD_TIERS, RELATIONSHIP_TIERS, Scored, Thresholds, Tier, W, isAmbiguous, isNumericLike, isTextLike, isTimestampLike, tierFor };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
//#region src/mapping-score.d.ts
|
|
2
|
+
type Tier = "High" | "Medium" | "Low";
|
|
3
|
+
interface Scored<T> {
|
|
4
|
+
/** Human-readable chips, e.g. `type text`, `name matches "contacts"`. */
|
|
5
|
+
reasons: string[];
|
|
6
|
+
/** Internal — used for ranking and the apply threshold. Never rendered. */
|
|
7
|
+
score: number;
|
|
8
|
+
tier: Tier;
|
|
9
|
+
value: T;
|
|
10
|
+
}
|
|
11
|
+
/** Tier cut-offs: score >= high → High; >= medium → Medium; else Low. */
|
|
12
|
+
interface Thresholds {
|
|
13
|
+
high: number;
|
|
14
|
+
medium: number;
|
|
15
|
+
}
|
|
16
|
+
declare const CONCEPT_TIERS: Thresholds;
|
|
17
|
+
declare const FIELD_TIERS: Thresholds;
|
|
18
|
+
declare const RELATIONSHIP_TIERS: Thresholds;
|
|
19
|
+
/** Two concept candidates closer than this are treated as ambiguous. */
|
|
20
|
+
declare const AMBIGUITY_MARGIN = 15;
|
|
21
|
+
/** Signal weights. Tuned against fixtures; adjust here, not at call sites. */
|
|
22
|
+
declare const W: {
|
|
23
|
+
readonly nameExact: 50;
|
|
24
|
+
readonly nameSubstring: 25;
|
|
25
|
+
readonly definingColumn: 20;
|
|
26
|
+
readonly hasPrimaryKey: 10;
|
|
27
|
+
readonly hasRows: 10;
|
|
28
|
+
readonly emptyTable: -20;
|
|
29
|
+
readonly fieldNameMatch: 50;
|
|
30
|
+
readonly typeCompatible: 30;
|
|
31
|
+
readonly typeMismatch: -30;
|
|
32
|
+
readonly requiredNotNull: 10;
|
|
33
|
+
readonly fkResolved: 60;
|
|
34
|
+
readonly fkNameHint: 30;
|
|
35
|
+
readonly joinTable: 20;
|
|
36
|
+
};
|
|
37
|
+
declare function tierFor(score: number, t: Thresholds): Tier;
|
|
38
|
+
declare function isTextLike(type: string | undefined): boolean;
|
|
39
|
+
declare function isNumericLike(type: string | undefined): boolean;
|
|
40
|
+
declare function isTimestampLike(type: string | undefined): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* True when the top two candidates are too close to call. Callers must never
|
|
43
|
+
* one-click apply an ambiguous result — the user picks.
|
|
44
|
+
*/
|
|
45
|
+
declare function isAmbiguous(ranked: Array<{
|
|
46
|
+
score: number;
|
|
47
|
+
}>): boolean;
|
|
48
|
+
//#endregion
|
|
49
|
+
export { AMBIGUITY_MARGIN, CONCEPT_TIERS, FIELD_TIERS, RELATIONSHIP_TIERS, Scored, Thresholds, Tier, W, isAmbiguous, isNumericLike, isTextLike, isTimestampLike, tierFor };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
//#region src/mapping-score.ts
|
|
2
|
+
const CONCEPT_TIERS = {
|
|
3
|
+
high: 70,
|
|
4
|
+
medium: 40
|
|
5
|
+
};
|
|
6
|
+
const FIELD_TIERS = {
|
|
7
|
+
high: 70,
|
|
8
|
+
medium: 40
|
|
9
|
+
};
|
|
10
|
+
const RELATIONSHIP_TIERS = {
|
|
11
|
+
high: 60,
|
|
12
|
+
medium: 30
|
|
13
|
+
};
|
|
14
|
+
/** Two concept candidates closer than this are treated as ambiguous. */
|
|
15
|
+
const AMBIGUITY_MARGIN = 15;
|
|
16
|
+
/** Signal weights. Tuned against fixtures; adjust here, not at call sites. */
|
|
17
|
+
const W = {
|
|
18
|
+
nameExact: 50,
|
|
19
|
+
nameSubstring: 25,
|
|
20
|
+
definingColumn: 20,
|
|
21
|
+
hasPrimaryKey: 10,
|
|
22
|
+
hasRows: 10,
|
|
23
|
+
emptyTable: -20,
|
|
24
|
+
fieldNameMatch: 50,
|
|
25
|
+
typeCompatible: 30,
|
|
26
|
+
typeMismatch: -30,
|
|
27
|
+
requiredNotNull: 10,
|
|
28
|
+
fkResolved: 60,
|
|
29
|
+
fkNameHint: 30,
|
|
30
|
+
joinTable: 20
|
|
31
|
+
};
|
|
32
|
+
function tierFor(score, t) {
|
|
33
|
+
if (score >= t.high) return "High";
|
|
34
|
+
if (score >= t.medium) return "Medium";
|
|
35
|
+
return "Low";
|
|
36
|
+
}
|
|
37
|
+
const TEXT_TYPES = [
|
|
38
|
+
"text",
|
|
39
|
+
"varchar",
|
|
40
|
+
"char",
|
|
41
|
+
"citext",
|
|
42
|
+
"uuid",
|
|
43
|
+
"string"
|
|
44
|
+
];
|
|
45
|
+
const NUMERIC_TYPES = [
|
|
46
|
+
"numeric",
|
|
47
|
+
"decimal",
|
|
48
|
+
"int",
|
|
49
|
+
"integer",
|
|
50
|
+
"bigint",
|
|
51
|
+
"smallint",
|
|
52
|
+
"float",
|
|
53
|
+
"double",
|
|
54
|
+
"real",
|
|
55
|
+
"money"
|
|
56
|
+
];
|
|
57
|
+
const TIMESTAMP_TYPES = [
|
|
58
|
+
"timestamp",
|
|
59
|
+
"timestamptz",
|
|
60
|
+
"date",
|
|
61
|
+
"datetime"
|
|
62
|
+
];
|
|
63
|
+
function matchesType(type, list) {
|
|
64
|
+
if (!type) return false;
|
|
65
|
+
const normalized = type.toLowerCase();
|
|
66
|
+
return list.some((t) => normalized.startsWith(t));
|
|
67
|
+
}
|
|
68
|
+
function isTextLike(type) {
|
|
69
|
+
return matchesType(type, TEXT_TYPES);
|
|
70
|
+
}
|
|
71
|
+
function isNumericLike(type) {
|
|
72
|
+
return matchesType(type, NUMERIC_TYPES);
|
|
73
|
+
}
|
|
74
|
+
function isTimestampLike(type) {
|
|
75
|
+
return matchesType(type, TIMESTAMP_TYPES);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* True when the top two candidates are too close to call. Callers must never
|
|
79
|
+
* one-click apply an ambiguous result — the user picks.
|
|
80
|
+
*/
|
|
81
|
+
function isAmbiguous(ranked) {
|
|
82
|
+
const [first, second] = ranked;
|
|
83
|
+
if (!(first && second)) return false;
|
|
84
|
+
return first.score - second.score < 15;
|
|
85
|
+
}
|
|
86
|
+
//#endregion
|
|
87
|
+
export { AMBIGUITY_MARGIN, CONCEPT_TIERS, FIELD_TIERS, RELATIONSHIP_TIERS, W, isAmbiguous, isNumericLike, isTextLike, isTimestampLike, tierFor };
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_entity_config = require("./entity-config.cjs");
|
|
3
|
+
//#region src/mappings-config.ts
|
|
4
|
+
const RESERVED_FIELD = "id";
|
|
5
|
+
/**
|
|
6
|
+
* Restrict a persisted field map to what the mirror can actually store: drop
|
|
7
|
+
* the reserved `id` key (for every concept) and, for materialized concepts
|
|
8
|
+
* (contact/company), any key that isn't canonical. This makes the pipeline
|
|
9
|
+
* resilient to legacy or mis-suggested field maps — a stray `id` no longer
|
|
10
|
+
* blocks the whole sync.
|
|
11
|
+
*/
|
|
12
|
+
function canonicalFields(concept, fieldMap) {
|
|
13
|
+
const allowed = require_entity_config.CANONICAL_FIELDS[concept];
|
|
14
|
+
const result = {};
|
|
15
|
+
for (const [field, column] of Object.entries(fieldMap)) {
|
|
16
|
+
if (field === RESERVED_FIELD) continue;
|
|
17
|
+
if (allowed && !allowed.includes(field)) continue;
|
|
18
|
+
result[field] = column;
|
|
19
|
+
}
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
const DEFAULT_ROLE = "primary";
|
|
23
|
+
const ACTIVE_STATUS = "active";
|
|
24
|
+
function isEligible(row) {
|
|
25
|
+
return (row.status ?? ACTIVE_STATUS) === ACTIVE_STATUS && Object.keys(row.fieldMap ?? {}).length > 0;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Preferred key for a row: the bare source table when it backs a single entity,
|
|
29
|
+
* else role-suffixed so multiple roles on one table stay distinct and stable
|
|
30
|
+
* (the primary role keeps the bare name). Uniqueness is still enforced by the
|
|
31
|
+
* caller in case two rows land on the same preferred key.
|
|
32
|
+
*/
|
|
33
|
+
function preferredKey(row, tableCounts) {
|
|
34
|
+
const role = row.role ?? DEFAULT_ROLE;
|
|
35
|
+
if ((tableCounts.get(row.sourceTable) ?? 0) <= 1) return row.sourceTable;
|
|
36
|
+
return role === DEFAULT_ROLE ? row.sourceTable : `${row.sourceTable}__${role}`;
|
|
37
|
+
}
|
|
38
|
+
function uniquify(base, used) {
|
|
39
|
+
if (!used.has(base)) {
|
|
40
|
+
used.add(base);
|
|
41
|
+
return base;
|
|
42
|
+
}
|
|
43
|
+
let suffix = 2;
|
|
44
|
+
while (used.has(`${base}__${suffix}`)) suffix++;
|
|
45
|
+
const key = `${base}__${suffix}`;
|
|
46
|
+
used.add(key);
|
|
47
|
+
return key;
|
|
48
|
+
}
|
|
49
|
+
function buildEntity(row, refIndex) {
|
|
50
|
+
const entity = {
|
|
51
|
+
concept: row.targetConcept,
|
|
52
|
+
source: row.sourceTable,
|
|
53
|
+
role: row.role ?? DEFAULT_ROLE,
|
|
54
|
+
fields: canonicalFields(row.targetConcept, row.fieldMap)
|
|
55
|
+
};
|
|
56
|
+
if (row.targetConcept === "custom" && row.targetObjectTypeId) entity.targetObjectTypeId = row.targetObjectTypeId;
|
|
57
|
+
if (row.filter && Object.keys(row.filter).length > 0) entity.filter = row.filter;
|
|
58
|
+
if (row.relationships && Object.keys(row.relationships).length > 0) {
|
|
59
|
+
const relationships = {};
|
|
60
|
+
for (const [name, spec] of Object.entries(row.relationships)) {
|
|
61
|
+
const rel = spec;
|
|
62
|
+
relationships[name] = {
|
|
63
|
+
entity: refIndex.get(rel.entity) ?? rel.entity,
|
|
64
|
+
via: rel.via
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
entity.relationships = relationships;
|
|
68
|
+
}
|
|
69
|
+
return entity;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Compile persisted mapping rows into a `ClivlyEntitiesConfig`. Only `active`
|
|
73
|
+
* rows with at least one mapped field are included; disabled/suggested rows and
|
|
74
|
+
* unmapped rows are skipped. Returns `{ entities: {} }` when nothing is
|
|
75
|
+
* eligible — a no-op the view compiler and sync engine both tolerate.
|
|
76
|
+
*/
|
|
77
|
+
function mappingsToEntitiesConfig(rows) {
|
|
78
|
+
const eligible = rows.filter(isEligible);
|
|
79
|
+
const tableCounts = /* @__PURE__ */ new Map();
|
|
80
|
+
for (const row of eligible) tableCounts.set(row.sourceTable, (tableCounts.get(row.sourceTable) ?? 0) + 1);
|
|
81
|
+
const used = /* @__PURE__ */ new Set();
|
|
82
|
+
const tableAnchor = /* @__PURE__ */ new Map();
|
|
83
|
+
const assigned = [];
|
|
84
|
+
for (const row of eligible) {
|
|
85
|
+
const key = uniquify(preferredKey(row, tableCounts), used);
|
|
86
|
+
assigned.push({
|
|
87
|
+
row,
|
|
88
|
+
key
|
|
89
|
+
});
|
|
90
|
+
if ((row.role ?? DEFAULT_ROLE) === DEFAULT_ROLE || !tableAnchor.has(row.sourceTable)) tableAnchor.set(row.sourceTable, key);
|
|
91
|
+
}
|
|
92
|
+
const refIndex = /* @__PURE__ */ new Map();
|
|
93
|
+
for (const { key } of assigned) refIndex.set(key, key);
|
|
94
|
+
for (const [table, key] of tableAnchor) if (!refIndex.has(table)) refIndex.set(table, key);
|
|
95
|
+
const entities = {};
|
|
96
|
+
for (const { row, key } of assigned) entities[key] = buildEntity(row, refIndex);
|
|
97
|
+
return { entities };
|
|
98
|
+
}
|
|
99
|
+
//#endregion
|
|
100
|
+
exports.mappingsToEntitiesConfig = mappingsToEntitiesConfig;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { ClivlyEntitiesConfig } from "./entity-config.cjs";
|
|
2
|
+
|
|
3
|
+
//#region src/mappings-config.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Mappings → Entity Config bridge.
|
|
6
|
+
*
|
|
7
|
+
* `crm_entity_mappings` rows are the single source of truth for how a host
|
|
8
|
+
* schema maps onto Clivly's CRM concepts. This module compiles those rows into
|
|
9
|
+
* a `ClivlyEntitiesConfig` — the contract the view compiler and sync engine
|
|
10
|
+
* consume — so the persisted mapping and the runtime pipeline never diverge.
|
|
11
|
+
*
|
|
12
|
+
* The row model has no explicit entity-key column (it's keyed by
|
|
13
|
+
* `sourceTable + targetConcept + role`), but the config's `relationships`
|
|
14
|
+
* reference other entities *by key*. This builder therefore derives a stable
|
|
15
|
+
* key per row and resolves each relationship's `entity` reference through the
|
|
16
|
+
* same derivation — so a relationship stored as `entity: "<relatedTable>"`
|
|
17
|
+
* (the shape `mappings.suggest` produces) resolves to the right entity.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* A persisted `crm_entity_mappings` row, narrowed to the columns the builder
|
|
21
|
+
* reads. Structurally compatible with the Drizzle row (jsonb columns typed
|
|
22
|
+
* loosely) so the API layer can pass `db.select()` results straight in.
|
|
23
|
+
*/
|
|
24
|
+
interface EntityMappingRow {
|
|
25
|
+
fieldMap: Record<string, string>;
|
|
26
|
+
filter?: Record<string, unknown> | null;
|
|
27
|
+
relationships?: Record<string, unknown> | null;
|
|
28
|
+
role?: string | null;
|
|
29
|
+
sourceTable: string;
|
|
30
|
+
status?: string | null;
|
|
31
|
+
targetConcept: string;
|
|
32
|
+
targetObjectTypeId?: string | null;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Compile persisted mapping rows into a `ClivlyEntitiesConfig`. Only `active`
|
|
36
|
+
* rows with at least one mapped field are included; disabled/suggested rows and
|
|
37
|
+
* unmapped rows are skipped. Returns `{ entities: {} }` when nothing is
|
|
38
|
+
* eligible — a no-op the view compiler and sync engine both tolerate.
|
|
39
|
+
*/
|
|
40
|
+
declare function mappingsToEntitiesConfig(rows: EntityMappingRow[]): ClivlyEntitiesConfig;
|
|
41
|
+
//#endregion
|
|
42
|
+
export { EntityMappingRow, mappingsToEntitiesConfig };
|
package/dist/mappings-config.mjs
CHANGED
|
@@ -1,4 +1,23 @@
|
|
|
1
|
+
import { CANONICAL_FIELDS } from "./entity-config.mjs";
|
|
1
2
|
//#region src/mappings-config.ts
|
|
3
|
+
const RESERVED_FIELD = "id";
|
|
4
|
+
/**
|
|
5
|
+
* Restrict a persisted field map to what the mirror can actually store: drop
|
|
6
|
+
* the reserved `id` key (for every concept) and, for materialized concepts
|
|
7
|
+
* (contact/company), any key that isn't canonical. This makes the pipeline
|
|
8
|
+
* resilient to legacy or mis-suggested field maps — a stray `id` no longer
|
|
9
|
+
* blocks the whole sync.
|
|
10
|
+
*/
|
|
11
|
+
function canonicalFields(concept, fieldMap) {
|
|
12
|
+
const allowed = CANONICAL_FIELDS[concept];
|
|
13
|
+
const result = {};
|
|
14
|
+
for (const [field, column] of Object.entries(fieldMap)) {
|
|
15
|
+
if (field === RESERVED_FIELD) continue;
|
|
16
|
+
if (allowed && !allowed.includes(field)) continue;
|
|
17
|
+
result[field] = column;
|
|
18
|
+
}
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
2
21
|
const DEFAULT_ROLE = "primary";
|
|
3
22
|
const ACTIVE_STATUS = "active";
|
|
4
23
|
function isEligible(row) {
|
|
@@ -31,8 +50,9 @@ function buildEntity(row, refIndex) {
|
|
|
31
50
|
concept: row.targetConcept,
|
|
32
51
|
source: row.sourceTable,
|
|
33
52
|
role: row.role ?? DEFAULT_ROLE,
|
|
34
|
-
fields:
|
|
53
|
+
fields: canonicalFields(row.targetConcept, row.fieldMap)
|
|
35
54
|
};
|
|
55
|
+
if (row.targetConcept === "custom" && row.targetObjectTypeId) entity.targetObjectTypeId = row.targetObjectTypeId;
|
|
36
56
|
if (row.filter && Object.keys(row.filter).length > 0) entity.filter = row.filter;
|
|
37
57
|
if (row.relationships && Object.keys(row.relationships).length > 0) {
|
|
38
58
|
const relationships = {};
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_view_compiler = require("./view-compiler.cjs");
|
|
3
|
+
//#region src/sync-engine.ts
|
|
4
|
+
/**
|
|
5
|
+
* Diff a view's rows against the current mirror population for one entity.
|
|
6
|
+
*
|
|
7
|
+
* - source row with no mirror row → create
|
|
8
|
+
* - source row with a live mirror row → update (overwrite identity fields)
|
|
9
|
+
* - source row with an archived mirror → restore (re-activate + update)
|
|
10
|
+
* - live mirror row absent from source → archive (soft delete, keep history)
|
|
11
|
+
* - archived mirror row absent from source → no-op
|
|
12
|
+
*
|
|
13
|
+
* Source rows are de-duplicated by the reconciliation key (first wins). In
|
|
14
|
+
* simple mode (default) the key is `sourceRef`. In composite mode — used for
|
|
15
|
+
* `through` (join-table) entities, where the compiled view fans a person out
|
|
16
|
+
* into one row per membership — the key is `(sourceRef, companyRef)`, so a
|
|
17
|
+
* person in several companies materializes one mirror row per company and a
|
|
18
|
+
* single membership leaving archives only that one row.
|
|
19
|
+
*/
|
|
20
|
+
function reconcile(source, mirror, opts = {}) {
|
|
21
|
+
const keyOf = (sourceRef, companyRef) => opts.composite ? `${sourceRef}\0${companyRef ?? ""}` : sourceRef;
|
|
22
|
+
const seen = /* @__PURE__ */ new Set();
|
|
23
|
+
const rows = [];
|
|
24
|
+
for (const row of source) {
|
|
25
|
+
const key = keyOf(row.sourceRef, row.companyRef ?? null);
|
|
26
|
+
if (!seen.has(key)) {
|
|
27
|
+
seen.add(key);
|
|
28
|
+
rows.push(row);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const byKey = new Map(mirror.map((m) => [keyOf(m.sourceRef, m.companyRef), m]));
|
|
32
|
+
const liveKeys = /* @__PURE__ */ new Set();
|
|
33
|
+
const actions = [];
|
|
34
|
+
for (const row of rows) {
|
|
35
|
+
const companyRef = row.companyRef ?? null;
|
|
36
|
+
const key = keyOf(row.sourceRef, companyRef);
|
|
37
|
+
liveKeys.add(key);
|
|
38
|
+
const existing = byKey.get(key);
|
|
39
|
+
if (!existing) actions.push({
|
|
40
|
+
kind: "create",
|
|
41
|
+
sourceRef: row.sourceRef,
|
|
42
|
+
fields: row.fields,
|
|
43
|
+
companyRef
|
|
44
|
+
});
|
|
45
|
+
else if (existing.archivedAt) actions.push({
|
|
46
|
+
kind: "restore",
|
|
47
|
+
id: existing.id,
|
|
48
|
+
sourceRef: row.sourceRef,
|
|
49
|
+
fields: row.fields,
|
|
50
|
+
companyRef
|
|
51
|
+
});
|
|
52
|
+
else actions.push({
|
|
53
|
+
kind: "update",
|
|
54
|
+
id: existing.id,
|
|
55
|
+
sourceRef: row.sourceRef,
|
|
56
|
+
fields: row.fields,
|
|
57
|
+
companyRef
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
for (const m of mirror) {
|
|
61
|
+
const key = keyOf(m.sourceRef, m.companyRef);
|
|
62
|
+
if (!(liveKeys.has(key) || m.archivedAt)) actions.push({
|
|
63
|
+
kind: "archive",
|
|
64
|
+
id: m.id,
|
|
65
|
+
sourceRef: m.sourceRef
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return actions;
|
|
69
|
+
}
|
|
70
|
+
const CONCEPT_ORDER = {
|
|
71
|
+
company: 0,
|
|
72
|
+
contact: 1
|
|
73
|
+
};
|
|
74
|
+
/** Companies before contacts, so contact company_refs resolve to a live mirror. */
|
|
75
|
+
function syncOrder(a, b) {
|
|
76
|
+
return (CONCEPT_ORDER[a.concept] ?? 99) - (CONCEPT_ORDER[b.concept] ?? 99);
|
|
77
|
+
}
|
|
78
|
+
async function resolveActions(store, actions) {
|
|
79
|
+
const refs = /* @__PURE__ */ new Set();
|
|
80
|
+
for (const action of actions) if (action.kind !== "archive" && action.companyRef) refs.add(action.companyRef);
|
|
81
|
+
const resolved = refs.size ? await store.resolveCompanyRefs([...refs]) : /* @__PURE__ */ new Map();
|
|
82
|
+
return actions.map((action) => {
|
|
83
|
+
if (action.kind === "archive") return action;
|
|
84
|
+
const companyId = action.companyRef ? resolved.get(action.companyRef) ?? null : null;
|
|
85
|
+
return {
|
|
86
|
+
kind: action.kind,
|
|
87
|
+
id: "id" in action ? action.id : "",
|
|
88
|
+
sourceRef: action.sourceRef,
|
|
89
|
+
fields: action.fields,
|
|
90
|
+
companyId,
|
|
91
|
+
companySourceRef: action.companyRef ?? null
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
function emptyTally() {
|
|
96
|
+
return {
|
|
97
|
+
create: 0,
|
|
98
|
+
update: 0,
|
|
99
|
+
restore: 0,
|
|
100
|
+
archive: 0
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* An entity whose company relationship is a `through` (join-table) join fans out
|
|
105
|
+
* into one source row per membership, so its mirror must be keyed compositely.
|
|
106
|
+
*/
|
|
107
|
+
function isCompositeEntity(config, entityKey) {
|
|
108
|
+
const entity = config.entities[entityKey];
|
|
109
|
+
if (!entity?.relationships) return false;
|
|
110
|
+
return Object.values(entity.relationships).some((rel) => "through" in rel.via);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Run a full sync: compile the config's views, then reconcile + persist each,
|
|
114
|
+
* companies first. Returns per-entity and total action counts.
|
|
115
|
+
*/
|
|
116
|
+
async function runSync(config, store, options = {}) {
|
|
117
|
+
const allViews = require_view_compiler.compileEntityViews(config, options).sort(syncOrder);
|
|
118
|
+
const views = options.sample ? allViews.filter((v) => v.entityKey === options.sample?.entityKey) : allViews;
|
|
119
|
+
const entities = [];
|
|
120
|
+
const totals = {
|
|
121
|
+
created: 0,
|
|
122
|
+
updated: 0,
|
|
123
|
+
restored: 0,
|
|
124
|
+
archived: 0
|
|
125
|
+
};
|
|
126
|
+
for (const view of views) {
|
|
127
|
+
const objectTypeId = config.entities[view.entityKey]?.targetObjectTypeId;
|
|
128
|
+
const [source, mirror] = await Promise.all([store.readView(view, options.sample ? { limit: 1 } : void 0), store.listMirror({
|
|
129
|
+
concept: view.concept,
|
|
130
|
+
role: view.role,
|
|
131
|
+
objectTypeId
|
|
132
|
+
})]);
|
|
133
|
+
const actions = reconcile(source, mirror, { composite: isCompositeEntity(config, view.entityKey) }).filter((a) => !(options.sample && a.kind === "archive"));
|
|
134
|
+
const persistActions = await resolveActions(store, actions);
|
|
135
|
+
await store.persist({
|
|
136
|
+
concept: view.concept,
|
|
137
|
+
role: view.role,
|
|
138
|
+
objectTypeId,
|
|
139
|
+
actions: persistActions
|
|
140
|
+
});
|
|
141
|
+
const counts = emptyTally();
|
|
142
|
+
for (const action of actions) counts[action.kind] += 1;
|
|
143
|
+
const result = {
|
|
144
|
+
entityKey: view.entityKey,
|
|
145
|
+
concept: view.concept,
|
|
146
|
+
role: view.role,
|
|
147
|
+
created: counts.create,
|
|
148
|
+
updated: counts.update,
|
|
149
|
+
restored: counts.restore,
|
|
150
|
+
archived: counts.archive
|
|
151
|
+
};
|
|
152
|
+
entities.push(result);
|
|
153
|
+
totals.created += result.created;
|
|
154
|
+
totals.updated += result.updated;
|
|
155
|
+
totals.restored += result.restored;
|
|
156
|
+
totals.archived += result.archived;
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
entities,
|
|
160
|
+
totals
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function isEmptyValue(value) {
|
|
164
|
+
return value === null || value === void 0 || value === "";
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Sample-scoped data-quality warnings over projected preview rows. Each mapped
|
|
168
|
+
* (or required-but-unmapped) field yields at most one warning, by precedence:
|
|
169
|
+
* a required field empty in ≥1 row → `K/N` form; an optional field empty in
|
|
170
|
+
* ALL rows → all-empty form; zero source rows → a single table-level warning.
|
|
171
|
+
*/
|
|
172
|
+
function computeSampleWarnings(rows, requiredFields, sourceTable) {
|
|
173
|
+
if (rows.length === 0) return [{ message: `no rows found in ${sourceTable}` }];
|
|
174
|
+
const required = new Set(requiredFields);
|
|
175
|
+
const fields = new Set(requiredFields);
|
|
176
|
+
for (const row of rows) for (const key of Object.keys(row.fields)) fields.add(key);
|
|
177
|
+
const warnings = [];
|
|
178
|
+
for (const field of [...fields].sort()) {
|
|
179
|
+
const emptyCount = rows.filter((row) => isEmptyValue(row.fields[field])).length;
|
|
180
|
+
if (required.has(field)) {
|
|
181
|
+
if (emptyCount > 0) warnings.push({
|
|
182
|
+
field,
|
|
183
|
+
message: `${field} empty in ${emptyCount}/${rows.length} sampled`
|
|
184
|
+
});
|
|
185
|
+
} else if (emptyCount === rows.length) warnings.push({
|
|
186
|
+
field,
|
|
187
|
+
message: `${field} is empty in all ${rows.length} sampled rows`
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
return warnings;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Zero-write preview: read ≤`limit` rows through the compiled view (a pure
|
|
194
|
+
* SELECT whose SQL already projects each row into its mapped fields) and
|
|
195
|
+
* compute sample warnings. Calls ONLY `store.readView` — never a write path —
|
|
196
|
+
* so it can never mutate the mirror. The distinct, write-one-row sibling is
|
|
197
|
+
* `runSync`'s sample mode (T13).
|
|
198
|
+
*/
|
|
199
|
+
async function previewSync(store, view, opts) {
|
|
200
|
+
const rows = await store.readView(view, { limit: opts.limit });
|
|
201
|
+
return {
|
|
202
|
+
rows,
|
|
203
|
+
sampledCount: rows.length,
|
|
204
|
+
warnings: computeSampleWarnings(rows, opts.requiredFields, opts.sourceTable)
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
//#endregion
|
|
208
|
+
exports.computeSampleWarnings = computeSampleWarnings;
|
|
209
|
+
exports.previewSync = previewSync;
|
|
210
|
+
exports.reconcile = reconcile;
|
|
211
|
+
exports.runSync = runSync;
|