@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.
Files changed (44) hide show
  1. package/README.md +57 -0
  2. package/dist/adapter.cjs +0 -0
  3. package/dist/adapter.d.cts +46 -0
  4. package/dist/adapter.d.mts +16 -3
  5. package/dist/auth-adapter.cjs +7 -0
  6. package/dist/auth-adapter.d.cts +30 -0
  7. package/dist/drizzle.cjs +32 -0
  8. package/dist/drizzle.d.cts +21 -0
  9. package/dist/drizzle.d.mts +21 -0
  10. package/dist/drizzle.mjs +31 -0
  11. package/dist/entity-config.cjs +350 -0
  12. package/dist/entity-config.d.cts +296 -0
  13. package/dist/entity-config.d.mts +46 -8
  14. package/dist/entity-config.mjs +86 -13
  15. package/dist/entity-heuristics.cjs +442 -0
  16. package/dist/entity-heuristics.d.cts +68 -0
  17. package/dist/entity-heuristics.d.mts +68 -0
  18. package/dist/entity-heuristics.mjs +431 -0
  19. package/dist/index.cjs +27 -0
  20. package/dist/index.d.cts +9 -0
  21. package/dist/index.d.mts +4 -4
  22. package/dist/index.mjs +3 -3
  23. package/dist/mapping-form.cjs +50 -0
  24. package/dist/mapping-form.d.cts +35 -0
  25. package/dist/mapping-score.cjs +97 -0
  26. package/dist/mapping-score.d.cts +49 -0
  27. package/dist/mapping-score.d.mts +49 -0
  28. package/dist/mapping-score.mjs +87 -0
  29. package/dist/mappings-config.cjs +100 -0
  30. package/dist/mappings-config.d.cts +42 -0
  31. package/dist/mappings-config.d.mts +1 -0
  32. package/dist/mappings-config.mjs +21 -1
  33. package/dist/sync-engine.cjs +211 -0
  34. package/dist/sync-engine.d.cts +183 -0
  35. package/dist/sync-engine.d.mts +54 -9
  36. package/dist/sync-engine.mjs +91 -22
  37. package/dist/types.cjs +0 -0
  38. package/dist/types.d.cts +159 -0
  39. package/dist/types.d.mts +12 -1
  40. package/dist/view-compiler.cjs +188 -0
  41. package/dist/view-compiler.d.cts +71 -0
  42. package/dist/view-compiler.d.mts +29 -1
  43. package/dist/view-compiler.mjs +31 -1
  44. 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 };
@@ -29,6 +29,7 @@ interface EntityMappingRow {
29
29
  sourceTable: string;
30
30
  status?: string | null;
31
31
  targetConcept: string;
32
+ targetObjectTypeId?: string | null;
32
33
  }
33
34
  /**
34
35
  * Compile persisted mapping rows into a `ClivlyEntitiesConfig`. Only `active`
@@ -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: { ...row.fieldMap }
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;