@clivly/core 0.1.0
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/LICENSE +21 -0
- package/README.md +29 -0
- package/dist/adapter.d.mts +33 -0
- package/dist/adapter.mjs +1 -0
- package/dist/auth-adapter.d.mts +30 -0
- package/dist/auth-adapter.mjs +6 -0
- package/dist/entity-config.d.mts +258 -0
- package/dist/entity-config.mjs +266 -0
- package/dist/index.d.mts +9 -0
- package/dist/index.mjs +7 -0
- package/dist/mapping-form.d.mts +35 -0
- package/dist/mapping-form.mjs +47 -0
- package/dist/mappings-config.d.mts +41 -0
- package/dist/mappings-config.mjs +79 -0
- package/dist/sync-engine.d.mts +138 -0
- package/dist/sync-engine.mjs +138 -0
- package/dist/types.d.mts +148 -0
- package/dist/types.mjs +1 -0
- package/dist/view-compiler.d.mts +43 -0
- package/dist/view-compiler.mjs +155 -0
- package/package.json +69 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { FilterExpr, RelationshipVia, RelationshipsMap } from "./entity-config.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/mapping-form.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* A relationship the mapping UI can offer or has configured, in the shape the
|
|
6
|
+
* heuristic suggester produces. `entityTable` is the *host table name*; the
|
|
7
|
+
* mappings→config compiler resolves it to the canonical entity key, so it is
|
|
8
|
+
* persisted verbatim as the relationship's `entity` reference.
|
|
9
|
+
*/
|
|
10
|
+
interface RelationshipCandidate {
|
|
11
|
+
entityTable: string;
|
|
12
|
+
name: string;
|
|
13
|
+
via: RelationshipVia;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Build the filter for a discriminated entity: rows where `column` equals
|
|
17
|
+
* `value` belong to this entity (e.g. `role = "owner"`). Returns `undefined`
|
|
18
|
+
* when either side is blank so the caller can omit the filter entirely.
|
|
19
|
+
*/
|
|
20
|
+
declare function buildFilterFromDiscriminator(column: string | null | undefined, value: string): FilterExpr | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* A stable identity string for a relationship's `via`. Two candidates on the
|
|
23
|
+
* same source table always differ in their join (`fkOn`+column, join-table
|
|
24
|
+
* keys, or raw SQL), so this disambiguates candidates the heuristic gives the
|
|
25
|
+
* same `name` (every contact→company candidate is named "company").
|
|
26
|
+
*/
|
|
27
|
+
declare function relationshipViaSignature(via: RelationshipVia): string;
|
|
28
|
+
/**
|
|
29
|
+
* Compile the selected relationship candidates into the persisted
|
|
30
|
+
* `RelationshipsMap`. Returns `undefined` for an empty selection so the caller
|
|
31
|
+
* omits the field.
|
|
32
|
+
*/
|
|
33
|
+
declare function buildRelationships(candidates: RelationshipCandidate[]): RelationshipsMap | undefined;
|
|
34
|
+
//#endregion
|
|
35
|
+
export { RelationshipCandidate, buildFilterFromDiscriminator, buildRelationships, relationshipViaSignature };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
//#region src/mapping-form.ts
|
|
2
|
+
/**
|
|
3
|
+
* Build the filter for a discriminated entity: rows where `column` equals
|
|
4
|
+
* `value` belong to this entity (e.g. `role = "owner"`). Returns `undefined`
|
|
5
|
+
* when either side is blank so the caller can omit the filter entirely.
|
|
6
|
+
*/
|
|
7
|
+
function buildFilterFromDiscriminator(column, value) {
|
|
8
|
+
const col = column?.trim();
|
|
9
|
+
const val = value.trim();
|
|
10
|
+
if (!col || val === "") return;
|
|
11
|
+
return { [col]: { eq: val } };
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* A stable identity string for a relationship's `via`. Two candidates on the
|
|
15
|
+
* same source table always differ in their join (`fkOn`+column, join-table
|
|
16
|
+
* keys, or raw SQL), so this disambiguates candidates the heuristic gives the
|
|
17
|
+
* same `name` (every contact→company candidate is named "company").
|
|
18
|
+
*/
|
|
19
|
+
function relationshipViaSignature(via) {
|
|
20
|
+
if ("fkOn" in via) return `fk:${via.fkOn}:${via.column}`;
|
|
21
|
+
if ("through" in via) return `through:${via.through}:${via.localKey}:${via.foreignKey}`;
|
|
22
|
+
return `raw:${via.$raw}`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Compile the selected relationship candidates into the persisted
|
|
26
|
+
* `RelationshipsMap`. Returns `undefined` for an empty selection so the caller
|
|
27
|
+
* omits the field.
|
|
28
|
+
*/
|
|
29
|
+
function buildRelationships(candidates) {
|
|
30
|
+
if (candidates.length === 0) return;
|
|
31
|
+
const map = {};
|
|
32
|
+
for (const candidate of candidates) {
|
|
33
|
+
let key = candidate.name;
|
|
34
|
+
let suffix = 2;
|
|
35
|
+
while (key in map) {
|
|
36
|
+
key = `${candidate.name}_${suffix}`;
|
|
37
|
+
suffix++;
|
|
38
|
+
}
|
|
39
|
+
map[key] = {
|
|
40
|
+
entity: candidate.entityTable,
|
|
41
|
+
via: candidate.via
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return map;
|
|
45
|
+
}
|
|
46
|
+
//#endregion
|
|
47
|
+
export { buildFilterFromDiscriminator, buildRelationships, relationshipViaSignature };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { ClivlyEntitiesConfig } from "./entity-config.mjs";
|
|
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
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Compile persisted mapping rows into a `ClivlyEntitiesConfig`. Only `active`
|
|
35
|
+
* rows with at least one mapped field are included; disabled/suggested rows and
|
|
36
|
+
* unmapped rows are skipped. Returns `{ entities: {} }` when nothing is
|
|
37
|
+
* eligible — a no-op the view compiler and sync engine both tolerate.
|
|
38
|
+
*/
|
|
39
|
+
declare function mappingsToEntitiesConfig(rows: EntityMappingRow[]): ClivlyEntitiesConfig;
|
|
40
|
+
//#endregion
|
|
41
|
+
export { EntityMappingRow, mappingsToEntitiesConfig };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
//#region src/mappings-config.ts
|
|
2
|
+
const DEFAULT_ROLE = "primary";
|
|
3
|
+
const ACTIVE_STATUS = "active";
|
|
4
|
+
function isEligible(row) {
|
|
5
|
+
return (row.status ?? ACTIVE_STATUS) === ACTIVE_STATUS && Object.keys(row.fieldMap ?? {}).length > 0;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Preferred key for a row: the bare source table when it backs a single entity,
|
|
9
|
+
* else role-suffixed so multiple roles on one table stay distinct and stable
|
|
10
|
+
* (the primary role keeps the bare name). Uniqueness is still enforced by the
|
|
11
|
+
* caller in case two rows land on the same preferred key.
|
|
12
|
+
*/
|
|
13
|
+
function preferredKey(row, tableCounts) {
|
|
14
|
+
const role = row.role ?? DEFAULT_ROLE;
|
|
15
|
+
if ((tableCounts.get(row.sourceTable) ?? 0) <= 1) return row.sourceTable;
|
|
16
|
+
return role === DEFAULT_ROLE ? row.sourceTable : `${row.sourceTable}__${role}`;
|
|
17
|
+
}
|
|
18
|
+
function uniquify(base, used) {
|
|
19
|
+
if (!used.has(base)) {
|
|
20
|
+
used.add(base);
|
|
21
|
+
return base;
|
|
22
|
+
}
|
|
23
|
+
let suffix = 2;
|
|
24
|
+
while (used.has(`${base}__${suffix}`)) suffix++;
|
|
25
|
+
const key = `${base}__${suffix}`;
|
|
26
|
+
used.add(key);
|
|
27
|
+
return key;
|
|
28
|
+
}
|
|
29
|
+
function buildEntity(row, refIndex) {
|
|
30
|
+
const entity = {
|
|
31
|
+
concept: row.targetConcept,
|
|
32
|
+
source: row.sourceTable,
|
|
33
|
+
role: row.role ?? DEFAULT_ROLE,
|
|
34
|
+
fields: { ...row.fieldMap }
|
|
35
|
+
};
|
|
36
|
+
if (row.filter && Object.keys(row.filter).length > 0) entity.filter = row.filter;
|
|
37
|
+
if (row.relationships && Object.keys(row.relationships).length > 0) {
|
|
38
|
+
const relationships = {};
|
|
39
|
+
for (const [name, spec] of Object.entries(row.relationships)) {
|
|
40
|
+
const rel = spec;
|
|
41
|
+
relationships[name] = {
|
|
42
|
+
entity: refIndex.get(rel.entity) ?? rel.entity,
|
|
43
|
+
via: rel.via
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
entity.relationships = relationships;
|
|
47
|
+
}
|
|
48
|
+
return entity;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Compile persisted mapping rows into a `ClivlyEntitiesConfig`. Only `active`
|
|
52
|
+
* rows with at least one mapped field are included; disabled/suggested rows and
|
|
53
|
+
* unmapped rows are skipped. Returns `{ entities: {} }` when nothing is
|
|
54
|
+
* eligible — a no-op the view compiler and sync engine both tolerate.
|
|
55
|
+
*/
|
|
56
|
+
function mappingsToEntitiesConfig(rows) {
|
|
57
|
+
const eligible = rows.filter(isEligible);
|
|
58
|
+
const tableCounts = /* @__PURE__ */ new Map();
|
|
59
|
+
for (const row of eligible) tableCounts.set(row.sourceTable, (tableCounts.get(row.sourceTable) ?? 0) + 1);
|
|
60
|
+
const used = /* @__PURE__ */ new Set();
|
|
61
|
+
const tableAnchor = /* @__PURE__ */ new Map();
|
|
62
|
+
const assigned = [];
|
|
63
|
+
for (const row of eligible) {
|
|
64
|
+
const key = uniquify(preferredKey(row, tableCounts), used);
|
|
65
|
+
assigned.push({
|
|
66
|
+
row,
|
|
67
|
+
key
|
|
68
|
+
});
|
|
69
|
+
if ((row.role ?? DEFAULT_ROLE) === DEFAULT_ROLE || !tableAnchor.has(row.sourceTable)) tableAnchor.set(row.sourceTable, key);
|
|
70
|
+
}
|
|
71
|
+
const refIndex = /* @__PURE__ */ new Map();
|
|
72
|
+
for (const { key } of assigned) refIndex.set(key, key);
|
|
73
|
+
for (const [table, key] of tableAnchor) if (!refIndex.has(table)) refIndex.set(table, key);
|
|
74
|
+
const entities = {};
|
|
75
|
+
for (const { row, key } of assigned) entities[key] = buildEntity(row, refIndex);
|
|
76
|
+
return { entities };
|
|
77
|
+
}
|
|
78
|
+
//#endregion
|
|
79
|
+
export { mappingsToEntitiesConfig };
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { ClivlyEntitiesConfig } from "./entity-config.mjs";
|
|
2
|
+
import { CompileOptions, CompiledView } from "./view-compiler.mjs";
|
|
3
|
+
|
|
4
|
+
//#region src/sync-engine.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Sync engine (Phase 3) — materializes the read-only entity views into Clivly's
|
|
7
|
+
* own mirror tables (`crm_companies`, `crm_contacts`).
|
|
8
|
+
*
|
|
9
|
+
* The reconciliation is the heart of the design: rows present in a view are
|
|
10
|
+
* created or updated in the mirror; rows that *leave* a view (e.g. an owner
|
|
11
|
+
* demoted to a member) are **archived, not deleted**, so their deals/activities
|
|
12
|
+
* survive. Identity fields are host-owned and overwritten each run; Clivly-owned
|
|
13
|
+
* CRM fields are never touched here.
|
|
14
|
+
*
|
|
15
|
+
* `reconcile()` is a pure function and independently testable. `runSync()`
|
|
16
|
+
* orchestrates: compile views → for each (companies first, so contact
|
|
17
|
+
* `company_ref`s resolve to a live `crm_companies.id`) read the view, reconcile
|
|
18
|
+
* against the current mirror, resolve company refs, and persist. All DB I/O is
|
|
19
|
+
* behind the `SyncStore` port — core stays ORM-agnostic; the SDK/drizzle
|
|
20
|
+
* package provides the concrete store.
|
|
21
|
+
*/
|
|
22
|
+
/** A row read from a compiled view (what Postgres returns). */
|
|
23
|
+
interface SourceRow {
|
|
24
|
+
/** Host-side company key from `<relationship>_ref`; resolved before persist. */
|
|
25
|
+
companyRef?: string | null;
|
|
26
|
+
/** Mapped identity fields (clivly field → value). */
|
|
27
|
+
fields: Record<string, string | null>;
|
|
28
|
+
/** The source primary key — becomes the mirror row's `source_ref`. */
|
|
29
|
+
sourceRef: string;
|
|
30
|
+
}
|
|
31
|
+
/** An existing mirror row, as far as reconciliation needs to know. */
|
|
32
|
+
interface MirrorRecord {
|
|
33
|
+
archivedAt: Date | null;
|
|
34
|
+
id: string;
|
|
35
|
+
sourceRef: string;
|
|
36
|
+
}
|
|
37
|
+
type SyncAction = {
|
|
38
|
+
kind: "create";
|
|
39
|
+
sourceRef: string;
|
|
40
|
+
fields: Record<string, string | null>;
|
|
41
|
+
companyRef: string | null;
|
|
42
|
+
} | {
|
|
43
|
+
kind: "update";
|
|
44
|
+
id: string;
|
|
45
|
+
sourceRef: string;
|
|
46
|
+
fields: Record<string, string | null>;
|
|
47
|
+
companyRef: string | null;
|
|
48
|
+
} | {
|
|
49
|
+
kind: "restore";
|
|
50
|
+
id: string;
|
|
51
|
+
sourceRef: string;
|
|
52
|
+
fields: Record<string, string | null>;
|
|
53
|
+
companyRef: string | null;
|
|
54
|
+
} | {
|
|
55
|
+
kind: "archive";
|
|
56
|
+
id: string;
|
|
57
|
+
sourceRef: string;
|
|
58
|
+
};
|
|
59
|
+
/** A persist action with the company ref resolved to a `crm_companies.id`. */
|
|
60
|
+
type PersistAction = {
|
|
61
|
+
kind: "create";
|
|
62
|
+
sourceRef: string;
|
|
63
|
+
fields: Record<string, string | null>;
|
|
64
|
+
companyId: string | null;
|
|
65
|
+
} | {
|
|
66
|
+
kind: "update";
|
|
67
|
+
id: string;
|
|
68
|
+
sourceRef: string;
|
|
69
|
+
fields: Record<string, string | null>;
|
|
70
|
+
companyId: string | null;
|
|
71
|
+
} | {
|
|
72
|
+
kind: "restore";
|
|
73
|
+
id: string;
|
|
74
|
+
sourceRef: string;
|
|
75
|
+
fields: Record<string, string | null>;
|
|
76
|
+
companyId: string | null;
|
|
77
|
+
} | {
|
|
78
|
+
kind: "archive";
|
|
79
|
+
id: string;
|
|
80
|
+
sourceRef: string;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Diff a view's rows against the current mirror population for one entity.
|
|
84
|
+
*
|
|
85
|
+
* - source row with no mirror row → create
|
|
86
|
+
* - source row with a live mirror row → update (overwrite identity fields)
|
|
87
|
+
* - source row with an archived mirror → restore (re-activate + update)
|
|
88
|
+
* - live mirror row absent from source → archive (soft delete, keep history)
|
|
89
|
+
* - archived mirror row absent from source → no-op
|
|
90
|
+
*
|
|
91
|
+
* Source rows are de-duplicated by `sourceRef` (first wins). A person appearing
|
|
92
|
+
* under multiple companies (many-to-many via a join table) collapses to their
|
|
93
|
+
* first company here; richer many-to-many membership is deferred.
|
|
94
|
+
*/
|
|
95
|
+
declare function reconcile(source: SourceRow[], mirror: MirrorRecord[]): SyncAction[];
|
|
96
|
+
/** DB I/O port. The concrete store lives in the SDK/drizzle package. */
|
|
97
|
+
interface SyncStore {
|
|
98
|
+
/** Current mirror rows for a concept + role (including archived). */
|
|
99
|
+
listMirror(input: {
|
|
100
|
+
concept: string;
|
|
101
|
+
role: string;
|
|
102
|
+
}): Promise<MirrorRecord[]>;
|
|
103
|
+
/** Apply the resolved actions to the mirror table for this concept + role. */
|
|
104
|
+
persist(input: {
|
|
105
|
+
concept: string;
|
|
106
|
+
role: string;
|
|
107
|
+
actions: PersistAction[];
|
|
108
|
+
}): Promise<void>;
|
|
109
|
+
/** Execute the view's SQL and return its rows. */
|
|
110
|
+
readView(view: CompiledView): Promise<SourceRow[]>;
|
|
111
|
+
/** Map host-side company refs → `crm_companies.id` (already-synced companies only). */
|
|
112
|
+
resolveCompanyRefs(refs: string[]): Promise<Map<string, string>>;
|
|
113
|
+
}
|
|
114
|
+
interface EntitySyncResult {
|
|
115
|
+
archived: number;
|
|
116
|
+
concept: string;
|
|
117
|
+
created: number;
|
|
118
|
+
entityKey: string;
|
|
119
|
+
restored: number;
|
|
120
|
+
role: string;
|
|
121
|
+
updated: number;
|
|
122
|
+
}
|
|
123
|
+
interface SyncResult {
|
|
124
|
+
entities: EntitySyncResult[];
|
|
125
|
+
totals: {
|
|
126
|
+
created: number;
|
|
127
|
+
updated: number;
|
|
128
|
+
restored: number;
|
|
129
|
+
archived: number;
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Run a full sync: compile the config's views, then reconcile + persist each,
|
|
134
|
+
* companies first. Returns per-entity and total action counts.
|
|
135
|
+
*/
|
|
136
|
+
declare function runSync(config: ClivlyEntitiesConfig, store: SyncStore, options?: CompileOptions): Promise<SyncResult>;
|
|
137
|
+
//#endregion
|
|
138
|
+
export { EntitySyncResult, MirrorRecord, PersistAction, SourceRow, SyncAction, SyncResult, SyncStore, reconcile, runSync };
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { compileEntityViews } from "./view-compiler.mjs";
|
|
2
|
+
//#region src/sync-engine.ts
|
|
3
|
+
/**
|
|
4
|
+
* Diff a view's rows against the current mirror population for one entity.
|
|
5
|
+
*
|
|
6
|
+
* - source row with no mirror row → create
|
|
7
|
+
* - source row with a live mirror row → update (overwrite identity fields)
|
|
8
|
+
* - source row with an archived mirror → restore (re-activate + update)
|
|
9
|
+
* - live mirror row absent from source → archive (soft delete, keep history)
|
|
10
|
+
* - archived mirror row absent from source → no-op
|
|
11
|
+
*
|
|
12
|
+
* Source rows are de-duplicated by `sourceRef` (first wins). A person appearing
|
|
13
|
+
* under multiple companies (many-to-many via a join table) collapses to their
|
|
14
|
+
* first company here; richer many-to-many membership is deferred.
|
|
15
|
+
*/
|
|
16
|
+
function reconcile(source, mirror) {
|
|
17
|
+
const seen = /* @__PURE__ */ new Set();
|
|
18
|
+
const rows = [];
|
|
19
|
+
for (const row of source) if (!seen.has(row.sourceRef)) {
|
|
20
|
+
seen.add(row.sourceRef);
|
|
21
|
+
rows.push(row);
|
|
22
|
+
}
|
|
23
|
+
const byRef = new Map(mirror.map((m) => [m.sourceRef, m]));
|
|
24
|
+
const liveRefs = /* @__PURE__ */ new Set();
|
|
25
|
+
const actions = [];
|
|
26
|
+
for (const row of rows) {
|
|
27
|
+
liveRefs.add(row.sourceRef);
|
|
28
|
+
const existing = byRef.get(row.sourceRef);
|
|
29
|
+
const companyRef = row.companyRef ?? null;
|
|
30
|
+
if (!existing) actions.push({
|
|
31
|
+
kind: "create",
|
|
32
|
+
sourceRef: row.sourceRef,
|
|
33
|
+
fields: row.fields,
|
|
34
|
+
companyRef
|
|
35
|
+
});
|
|
36
|
+
else if (existing.archivedAt) actions.push({
|
|
37
|
+
kind: "restore",
|
|
38
|
+
id: existing.id,
|
|
39
|
+
sourceRef: row.sourceRef,
|
|
40
|
+
fields: row.fields,
|
|
41
|
+
companyRef
|
|
42
|
+
});
|
|
43
|
+
else actions.push({
|
|
44
|
+
kind: "update",
|
|
45
|
+
id: existing.id,
|
|
46
|
+
sourceRef: row.sourceRef,
|
|
47
|
+
fields: row.fields,
|
|
48
|
+
companyRef
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
for (const m of mirror) if (!(liveRefs.has(m.sourceRef) || m.archivedAt)) actions.push({
|
|
52
|
+
kind: "archive",
|
|
53
|
+
id: m.id,
|
|
54
|
+
sourceRef: m.sourceRef
|
|
55
|
+
});
|
|
56
|
+
return actions;
|
|
57
|
+
}
|
|
58
|
+
const CONCEPT_ORDER = {
|
|
59
|
+
company: 0,
|
|
60
|
+
contact: 1
|
|
61
|
+
};
|
|
62
|
+
/** Companies before contacts, so contact company_refs resolve to a live mirror. */
|
|
63
|
+
function syncOrder(a, b) {
|
|
64
|
+
return (CONCEPT_ORDER[a.concept] ?? 99) - (CONCEPT_ORDER[b.concept] ?? 99);
|
|
65
|
+
}
|
|
66
|
+
async function resolveActions(store, actions) {
|
|
67
|
+
const refs = /* @__PURE__ */ new Set();
|
|
68
|
+
for (const action of actions) if (action.kind !== "archive" && action.companyRef) refs.add(action.companyRef);
|
|
69
|
+
const resolved = refs.size ? await store.resolveCompanyRefs([...refs]) : /* @__PURE__ */ new Map();
|
|
70
|
+
return actions.map((action) => {
|
|
71
|
+
if (action.kind === "archive") return action;
|
|
72
|
+
const companyId = action.companyRef ? resolved.get(action.companyRef) ?? null : null;
|
|
73
|
+
return {
|
|
74
|
+
kind: action.kind,
|
|
75
|
+
id: "id" in action ? action.id : "",
|
|
76
|
+
sourceRef: action.sourceRef,
|
|
77
|
+
fields: action.fields,
|
|
78
|
+
companyId
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
function emptyTally() {
|
|
83
|
+
return {
|
|
84
|
+
create: 0,
|
|
85
|
+
update: 0,
|
|
86
|
+
restore: 0,
|
|
87
|
+
archive: 0
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Run a full sync: compile the config's views, then reconcile + persist each,
|
|
92
|
+
* companies first. Returns per-entity and total action counts.
|
|
93
|
+
*/
|
|
94
|
+
async function runSync(config, store, options = {}) {
|
|
95
|
+
const views = compileEntityViews(config, options).sort(syncOrder);
|
|
96
|
+
const entities = [];
|
|
97
|
+
const totals = {
|
|
98
|
+
created: 0,
|
|
99
|
+
updated: 0,
|
|
100
|
+
restored: 0,
|
|
101
|
+
archived: 0
|
|
102
|
+
};
|
|
103
|
+
for (const view of views) {
|
|
104
|
+
const [source, mirror] = await Promise.all([store.readView(view), store.listMirror({
|
|
105
|
+
concept: view.concept,
|
|
106
|
+
role: view.role
|
|
107
|
+
})]);
|
|
108
|
+
const actions = reconcile(source, mirror);
|
|
109
|
+
const persistActions = await resolveActions(store, actions);
|
|
110
|
+
await store.persist({
|
|
111
|
+
concept: view.concept,
|
|
112
|
+
role: view.role,
|
|
113
|
+
actions: persistActions
|
|
114
|
+
});
|
|
115
|
+
const counts = emptyTally();
|
|
116
|
+
for (const action of actions) counts[action.kind] += 1;
|
|
117
|
+
const result = {
|
|
118
|
+
entityKey: view.entityKey,
|
|
119
|
+
concept: view.concept,
|
|
120
|
+
role: view.role,
|
|
121
|
+
created: counts.create,
|
|
122
|
+
updated: counts.update,
|
|
123
|
+
restored: counts.restore,
|
|
124
|
+
archived: counts.archive
|
|
125
|
+
};
|
|
126
|
+
entities.push(result);
|
|
127
|
+
totals.created += result.created;
|
|
128
|
+
totals.updated += result.updated;
|
|
129
|
+
totals.restored += result.restored;
|
|
130
|
+
totals.archived += result.archived;
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
entities,
|
|
134
|
+
totals
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
//#endregion
|
|
138
|
+
export { reconcile, runSync };
|
package/dist/types.d.mts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
//#region src/types.d.ts
|
|
2
|
+
type ContactStatus = "lead" | "active" | "customer" | "churned";
|
|
3
|
+
type DealStage = "lead" | "qualified" | "proposal" | "negotiation" | "won" | "lost";
|
|
4
|
+
type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
|
|
5
|
+
type TaskPriority = "low" | "medium" | "high";
|
|
6
|
+
type TaskEntityType = "contact" | "company" | "deal";
|
|
7
|
+
type ActivityType = "note" | "email" | "task" | "deal" | "call";
|
|
8
|
+
type CrmRole = "owner" | "admin" | "member" | "developer" | "support_agent";
|
|
9
|
+
interface Contact {
|
|
10
|
+
companyId: string | null;
|
|
11
|
+
createdAt: Date;
|
|
12
|
+
email: string;
|
|
13
|
+
id: string;
|
|
14
|
+
lastActivityAt: Date | null;
|
|
15
|
+
name: string;
|
|
16
|
+
orgId: string;
|
|
17
|
+
status: ContactStatus;
|
|
18
|
+
title: string | null;
|
|
19
|
+
}
|
|
20
|
+
interface Company {
|
|
21
|
+
createdAt: Date;
|
|
22
|
+
domain: string | null;
|
|
23
|
+
id: string;
|
|
24
|
+
industry: string | null;
|
|
25
|
+
name: string;
|
|
26
|
+
orgId: string;
|
|
27
|
+
size: string | null;
|
|
28
|
+
}
|
|
29
|
+
interface Deal {
|
|
30
|
+
closeDate: Date | null;
|
|
31
|
+
contactId: string | null;
|
|
32
|
+
createdAt: Date;
|
|
33
|
+
id: string;
|
|
34
|
+
name: string;
|
|
35
|
+
orgId: string;
|
|
36
|
+
owner: string | null;
|
|
37
|
+
stage: DealStage;
|
|
38
|
+
value: number;
|
|
39
|
+
}
|
|
40
|
+
interface Task {
|
|
41
|
+
archivedAt: Date | null;
|
|
42
|
+
assignee: string | null;
|
|
43
|
+
completedAt: Date | null;
|
|
44
|
+
contactId: string | null;
|
|
45
|
+
createdAt: Date;
|
|
46
|
+
createdBy: string | null;
|
|
47
|
+
description: string | null;
|
|
48
|
+
dueAt: Date | null;
|
|
49
|
+
entityId: string | null;
|
|
50
|
+
entityType: TaskEntityType | null;
|
|
51
|
+
id: string;
|
|
52
|
+
orgId: string;
|
|
53
|
+
priority: TaskPriority;
|
|
54
|
+
status: TaskStatus;
|
|
55
|
+
title: string;
|
|
56
|
+
}
|
|
57
|
+
interface Note {
|
|
58
|
+
archivedAt: Date | null;
|
|
59
|
+
author: string | null;
|
|
60
|
+
body: string;
|
|
61
|
+
contactId: string | null;
|
|
62
|
+
createdAt: Date;
|
|
63
|
+
createdBy: string | null;
|
|
64
|
+
entityId: string | null;
|
|
65
|
+
entityType: TaskEntityType | null;
|
|
66
|
+
id: string;
|
|
67
|
+
isTemplate: boolean;
|
|
68
|
+
orgId: string;
|
|
69
|
+
title: string;
|
|
70
|
+
updatedAt: Date;
|
|
71
|
+
}
|
|
72
|
+
interface Activity {
|
|
73
|
+
actor: string | null;
|
|
74
|
+
contactId: string | null;
|
|
75
|
+
createdAt: Date;
|
|
76
|
+
id: string;
|
|
77
|
+
orgId: string;
|
|
78
|
+
summary: string;
|
|
79
|
+
type: ActivityType;
|
|
80
|
+
}
|
|
81
|
+
interface CreateContactInput {
|
|
82
|
+
companyId?: string;
|
|
83
|
+
email: string;
|
|
84
|
+
name: string;
|
|
85
|
+
status?: ContactStatus;
|
|
86
|
+
title?: string;
|
|
87
|
+
}
|
|
88
|
+
interface UpdateContactInput {
|
|
89
|
+
companyId?: string | null;
|
|
90
|
+
email?: string;
|
|
91
|
+
name?: string;
|
|
92
|
+
status?: ContactStatus;
|
|
93
|
+
title?: string | null;
|
|
94
|
+
}
|
|
95
|
+
interface CreateCompanyInput {
|
|
96
|
+
domain?: string;
|
|
97
|
+
industry?: string;
|
|
98
|
+
name: string;
|
|
99
|
+
size?: string;
|
|
100
|
+
}
|
|
101
|
+
interface CreateDealInput {
|
|
102
|
+
contactId?: string;
|
|
103
|
+
name: string;
|
|
104
|
+
owner?: string;
|
|
105
|
+
stage?: DealStage;
|
|
106
|
+
value?: number;
|
|
107
|
+
}
|
|
108
|
+
interface CreateTaskInput {
|
|
109
|
+
assignee?: string;
|
|
110
|
+
createdBy?: string;
|
|
111
|
+
description?: string;
|
|
112
|
+
dueAt?: Date;
|
|
113
|
+
entityId?: string;
|
|
114
|
+
entityType?: TaskEntityType;
|
|
115
|
+
priority?: TaskPriority;
|
|
116
|
+
title: string;
|
|
117
|
+
}
|
|
118
|
+
interface UpdateTaskInput {
|
|
119
|
+
assignee?: string | null;
|
|
120
|
+
description?: string | null;
|
|
121
|
+
dueAt?: Date | null;
|
|
122
|
+
entityId?: string | null;
|
|
123
|
+
entityType?: TaskEntityType | null;
|
|
124
|
+
priority?: TaskPriority;
|
|
125
|
+
title?: string;
|
|
126
|
+
}
|
|
127
|
+
interface CreateActivityInput {
|
|
128
|
+
actor?: string;
|
|
129
|
+
contactId?: string;
|
|
130
|
+
summary: string;
|
|
131
|
+
type: ActivityType;
|
|
132
|
+
}
|
|
133
|
+
interface ContactFilters {
|
|
134
|
+
companyId?: string;
|
|
135
|
+
search?: string;
|
|
136
|
+
status?: ContactStatus;
|
|
137
|
+
}
|
|
138
|
+
interface TaskFilters {
|
|
139
|
+
assignee?: string;
|
|
140
|
+
entityId?: string;
|
|
141
|
+
entityType?: TaskEntityType;
|
|
142
|
+
includeArchived?: boolean;
|
|
143
|
+
includeCompleted?: boolean;
|
|
144
|
+
priority?: TaskPriority;
|
|
145
|
+
status?: TaskStatus;
|
|
146
|
+
}
|
|
147
|
+
//#endregion
|
|
148
|
+
export { Activity, ActivityType, Company, Contact, ContactFilters, ContactStatus, CreateActivityInput, CreateCompanyInput, CreateContactInput, CreateDealInput, CreateTaskInput, CrmRole, Deal, DealStage, Note, Task, TaskEntityType, TaskFilters, TaskPriority, TaskStatus, UpdateContactInput, UpdateTaskInput };
|
package/dist/types.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { ClivlyEntitiesConfig } from "./entity-config.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/view-compiler.d.ts
|
|
4
|
+
interface CompileOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Optional tenant-scoping predicate ANDed into every view's WHERE, e.g.
|
|
7
|
+
* `{ column: "workspace_id", value: "ws_123" }`. Column must exist on the
|
|
8
|
+
* entity's source table (the compiler does not verify that — validate first).
|
|
9
|
+
*/
|
|
10
|
+
orgScope?: {
|
|
11
|
+
column: string;
|
|
12
|
+
value: string | number;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Emit views `WITH (security_invoker = true)` so they run with the querying
|
|
16
|
+
* role's privileges instead of the (owner's) definer privileges — the
|
|
17
|
+
* default Postgres behaviour, which bypasses the caller's RLS. Defaults to
|
|
18
|
+
* `true`: the security boundary the product promises. Set `false` only for
|
|
19
|
+
* hosts on Postgres < 15, which lacks the option.
|
|
20
|
+
*/
|
|
21
|
+
securityInvoker?: boolean;
|
|
22
|
+
/** View name prefix. Defaults to `crm_src_`. */
|
|
23
|
+
viewPrefix?: string;
|
|
24
|
+
}
|
|
25
|
+
interface CompiledView {
|
|
26
|
+
/** Output column names in SELECT order (`id`, mapped fields, then `<rel>_ref`s). */
|
|
27
|
+
columns: string[];
|
|
28
|
+
concept: string;
|
|
29
|
+
/** `DROP VIEW IF EXISTS …` statement (for teardown / reconfiguration). */
|
|
30
|
+
dropSql: string;
|
|
31
|
+
entityKey: string;
|
|
32
|
+
/** Resolved role — the entity's `role` or `"primary"`. */
|
|
33
|
+
role: string;
|
|
34
|
+
sourceTable: string;
|
|
35
|
+
/** `CREATE OR REPLACE VIEW … AS …` statement. */
|
|
36
|
+
sql: string;
|
|
37
|
+
viewName: string;
|
|
38
|
+
}
|
|
39
|
+
declare function compileEntityView(config: ClivlyEntitiesConfig, entityKey: string, options?: CompileOptions): CompiledView;
|
|
40
|
+
/** Compile every entity in the config into its view, in declaration order. */
|
|
41
|
+
declare function compileEntityViews(config: ClivlyEntitiesConfig, options?: CompileOptions): CompiledView[];
|
|
42
|
+
//#endregion
|
|
43
|
+
export { CompileOptions, CompiledView, compileEntityView, compileEntityViews };
|