clivly 0.12.0 → 0.14.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 +10 -3
  2. package/{adapter-DhPBRNpi.d.mts → adapter-B-iBMhl0.d.mts} +13 -0
  3. package/{adapter-DTMeGGsl.d.cts → adapter-JpXSAnj-.d.cts} +13 -0
  4. package/core-adapter.d.cts +1 -1
  5. package/core-adapter.d.mts +1 -1
  6. package/core-sync-engine.cjs +3 -1
  7. package/core-sync-engine.d.cts +2 -2
  8. package/core-sync-engine.d.mts +2 -2
  9. package/core-sync-engine.mjs +2 -2
  10. package/core.cjs +3 -1
  11. package/core.d.cts +3 -3
  12. package/core.d.mts +3 -3
  13. package/core.mjs +2 -2
  14. package/drizzle.cjs +65 -0
  15. package/drizzle.d.cts +51 -1
  16. package/drizzle.d.mts +51 -1
  17. package/drizzle.mjs +64 -1
  18. package/index.cjs +102 -1
  19. package/index.mjs +102 -1
  20. package/internal-adapter.d.cts +1 -1
  21. package/internal-adapter.d.mts +1 -1
  22. package/internal-sync-engine.cjs +3 -1
  23. package/internal-sync-engine.d.cts +2 -2
  24. package/internal-sync-engine.d.mts +2 -2
  25. package/internal-sync-engine.mjs +2 -2
  26. package/internal.cjs +3 -1
  27. package/internal.d.cts +3 -3
  28. package/internal.d.mts +3 -3
  29. package/internal.mjs +2 -2
  30. package/{namespace-probe-CNQKSmxz.d.mts → namespace-probe-DykR0kgh.d.mts} +42 -0
  31. package/{namespace-probe-C5RLl74M.d.cts → namespace-probe-H7yWc0JV.d.cts} +42 -0
  32. package/package.json +1 -1
  33. package/{sdk-C-Pjxj2y.mjs → sdk-DLoVPlGB.mjs} +177 -6
  34. package/{sdk-BvGSxBQc.cjs → sdk-ZMy4C4VK.cjs} +194 -5
  35. package/sdk.cjs +4 -1
  36. package/sdk.d.cts +33 -1
  37. package/sdk.d.mts +33 -1
  38. package/sdk.mjs +2 -2
  39. package/{sync-engine-Dwm_KDUS.cjs → sync-engine-BGVDsrOQ.cjs} +131 -71
  40. package/{sync-engine-V3_UutGv.d.cts → sync-engine-CYsWOujz.d.cts} +63 -22
  41. package/{sync-engine--M9EaONC.d.mts → sync-engine-C_5TpvfX.d.mts} +63 -22
  42. package/{sync-engine-Df2MCs_n.mjs → sync-engine-DH058G3w.mjs} +120 -72
  43. package/vite.d.cts +1 -1
  44. package/vite.d.mts +1 -1
@@ -1,67 +1,103 @@
1
1
  const require_view_compiler = require("./view-compiler-xiwm5iar.cjs");
2
2
  //#region ../core/src/sync-engine.ts
3
+ /** Actions that remove something — only safe when the source set is complete. */
4
+ function isRemovalAction(action) {
5
+ return action.kind === "archive" || action.kind === "endMembership";
6
+ }
7
+ /** Every distinct non-null company ref in a person's rows, in arrival order. */
8
+ function collectCompanyRefs(rows) {
9
+ const refs = [];
10
+ for (const row of rows) {
11
+ const ref = row.companyRef ?? null;
12
+ if (ref !== null && !refs.includes(ref)) refs.push(ref);
13
+ }
14
+ return refs;
15
+ }
16
+ /**
17
+ * The primary company for a person: the one they already have while it is
18
+ * still one of theirs — so the displayed organisation never flips between
19
+ * syncs — otherwise the lowest ref, which is deterministic for a new person.
20
+ */
21
+ function pickPrimaryRef(refs, current) {
22
+ if (current !== null && refs.includes(current)) return current;
23
+ return [...refs].sort()[0] ?? null;
24
+ }
25
+ /**
26
+ * The mirror row that represents a source identity. Normally there is exactly
27
+ * one; before contact consolidation has run there can be several (one per
28
+ * tenant), and the live, earliest-listed one stands for the person while the
29
+ * rest are left alone for the consolidation script to fold in.
30
+ */
31
+ function canonicalMirror(rows) {
32
+ return rows.find((row) => !row.archivedAt) ?? rows[0];
33
+ }
3
34
  /**
4
35
  * Diff a view's rows against the current mirror population for one entity.
5
36
  *
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
37
+ * - source identity with no mirror row → create
38
+ * - source identity with a live mirror row → update (overwrite identity fields)
39
+ * - source identity with an archived mirror → restore (re-activate + update)
40
+ * - live mirror row absent from source → archive (soft delete, keep history)
41
+ * - archived mirror row absent from source → no-op
11
42
  *
12
- * Source rows are de-duplicated by the reconciliation key (first wins). In
13
- * simple mode (default) the key is `sourceRef`. In composite mode — used for
14
- * `through` (join-table) entities, where the compiled view fans a person out
15
- * into one row per membership — the key is `(sourceRef, companyRef)`, so a
16
- * person in several companies materializes one mirror row per company and a
17
- * single membership leaving archives only that one row.
43
+ * Identity is always the source primary key. A `through` (join-table) view
44
+ * fans one person out into a row per tenant; those rows are ONE person, so they
45
+ * collapse into one action. With `memberships` on (contacts), that action also
46
+ * carries every tenant ref the person was seen with, and each tenant the mirror
47
+ * still has as active but the source no longer lists yields `endMembership` —
48
+ * a person leaving one tenant ends that membership and keeps the others.
18
49
  */
19
50
  function reconcile(source, mirror, opts = {}) {
20
- const keyOf = (sourceRef, companyRef) => opts.composite ? `${sourceRef}\0${companyRef ?? ""}` : sourceRef;
21
- const seen = /* @__PURE__ */ new Set();
22
- const rows = [];
23
- for (const row of source) {
24
- const key = keyOf(row.sourceRef, row.companyRef ?? null);
25
- if (!seen.has(key)) {
26
- seen.add(key);
27
- rows.push(row);
28
- }
29
- }
30
- const byKey = new Map(mirror.map((m) => [keyOf(m.sourceRef, m.companyRef), m]));
31
- const liveKeys = /* @__PURE__ */ new Set();
51
+ const rowsByRef = groupBySourceRef(source);
52
+ const mirrorByRef = groupBySourceRef(mirror);
32
53
  const actions = [];
33
- for (const row of rows) {
34
- const companyRef = row.companyRef ?? null;
35
- const key = keyOf(row.sourceRef, companyRef);
36
- liveKeys.add(key);
37
- const existing = byKey.get(key);
38
- if (!existing) actions.push({
39
- kind: "create",
40
- sourceRef: row.sourceRef,
41
- fields: row.fields,
42
- companyRef
43
- });
44
- else if (existing.archivedAt) actions.push({
45
- kind: "restore",
46
- id: existing.id,
47
- sourceRef: row.sourceRef,
48
- fields: row.fields,
49
- companyRef
50
- });
51
- else actions.push({
52
- kind: "update",
53
- id: existing.id,
54
- sourceRef: row.sourceRef,
55
- fields: row.fields,
56
- companyRef
57
- });
54
+ for (const [sourceRef, rows] of rowsByRef) {
55
+ const existing = canonicalMirror(mirrorByRef.get(sourceRef) ?? []);
56
+ actions.push(...personActions(sourceRef, rows, existing, opts));
57
+ }
58
+ for (const m of mirror) if (!(rowsByRef.has(m.sourceRef) || m.archivedAt)) actions.push({
59
+ kind: "archive",
60
+ id: m.id,
61
+ sourceRef: m.sourceRef
62
+ });
63
+ return actions;
64
+ }
65
+ function groupBySourceRef(items) {
66
+ const out = /* @__PURE__ */ new Map();
67
+ for (const item of items) {
68
+ const list = out.get(item.sourceRef);
69
+ if (list) list.push(item);
70
+ else out.set(item.sourceRef, [item]);
58
71
  }
59
- for (const m of mirror) {
60
- const key = keyOf(m.sourceRef, m.companyRef);
61
- if (!(liveKeys.has(key) || m.archivedAt)) actions.push({
62
- kind: "archive",
63
- id: m.id,
64
- sourceRef: m.sourceRef
72
+ return out;
73
+ }
74
+ /** The create/update/restore for one present person, plus any ended tenants. */
75
+ function personActions(sourceRef, rows, existing, opts) {
76
+ const first = rows[0];
77
+ const refs = collectCompanyRefs(rows);
78
+ const companyRef = opts.memberships ? pickPrimaryRef(refs, existing?.companyRef ?? null) : first.companyRef ?? null;
79
+ const membershipSet = opts.memberships ? { companyRefs: refs } : {};
80
+ if (!existing) return [{
81
+ kind: "create",
82
+ sourceRef,
83
+ fields: first.fields,
84
+ companyRef,
85
+ ...membershipSet
86
+ }];
87
+ const actions = [{
88
+ kind: existing.archivedAt ? "restore" : "update",
89
+ id: existing.id,
90
+ sourceRef,
91
+ fields: first.fields,
92
+ companyRef,
93
+ ...membershipSet
94
+ }];
95
+ if (opts.memberships) {
96
+ for (const held of existing.companyRefs ?? []) if (!refs.includes(held)) actions.push({
97
+ kind: "endMembership",
98
+ id: existing.id,
99
+ sourceRef,
100
+ companyRef: held
65
101
  });
66
102
  }
67
103
  return actions;
@@ -74,20 +110,40 @@ const CONCEPT_ORDER = {
74
110
  function syncOrder(a, b) {
75
111
  return (CONCEPT_ORDER[a.concept] ?? 99) - (CONCEPT_ORDER[b.concept] ?? 99);
76
112
  }
113
+ /**
114
+ * Resolve each action's host-side company refs — the primary one and, for
115
+ * contacts, every membership — to `crm_companies.id`s in one lookup. Shared by
116
+ * `runSync` and the cloud push receiver so both persist identical actions.
117
+ */
77
118
  async function resolveActions(store, actions) {
78
119
  const refs = /* @__PURE__ */ new Set();
79
- for (const action of actions) if (action.kind !== "archive" && action.companyRef) refs.add(action.companyRef);
120
+ for (const action of actions) {
121
+ if (isRemovalAction(action) || !("fields" in action)) continue;
122
+ if (action.companyRef) refs.add(action.companyRef);
123
+ for (const ref of action.companyRefs ?? []) refs.add(ref);
124
+ }
80
125
  const resolved = refs.size ? await store.resolveCompanyRefs([...refs]) : /* @__PURE__ */ new Map();
81
126
  return actions.map((action) => {
82
- if (action.kind === "archive") return action;
127
+ if (action.kind === "archive" || action.kind === "endMembership") return action;
83
128
  const companyId = action.companyRef ? resolved.get(action.companyRef) ?? null : null;
84
- return {
85
- kind: action.kind,
86
- id: "id" in action ? action.id : "",
129
+ const memberships = action.companyRefs?.map((companyRef) => ({
130
+ companyRef,
131
+ companyId: resolved.get(companyRef) ?? null
132
+ }));
133
+ const base = {
87
134
  sourceRef: action.sourceRef,
88
135
  fields: action.fields,
89
136
  companyId,
90
- companySourceRef: action.companyRef ?? null
137
+ companySourceRef: action.companyRef ?? null,
138
+ ...memberships ? { memberships } : {}
139
+ };
140
+ return action.kind === "create" ? {
141
+ kind: "create",
142
+ ...base
143
+ } : {
144
+ kind: action.kind,
145
+ id: action.id,
146
+ ...base
91
147
  };
92
148
  });
93
149
  }
@@ -96,19 +152,11 @@ function emptyTally() {
96
152
  create: 0,
97
153
  update: 0,
98
154
  restore: 0,
99
- archive: 0
155
+ archive: 0,
156
+ endMembership: 0
100
157
  };
101
158
  }
102
159
  /**
103
- * An entity whose company relationship is a `through` (join-table) join fans out
104
- * into one source row per membership, so its mirror must be keyed compositely.
105
- */
106
- function isCompositeEntity(config, entityKey) {
107
- const entity = config.entities[entityKey];
108
- if (!entity?.relationships) return false;
109
- return Object.values(entity.relationships).some((rel) => "through" in rel.via);
110
- }
111
- /**
112
160
  * Run a full sync: compile the config's views, then reconcile + persist each,
113
161
  * companies first. Returns per-entity and total action counts.
114
162
  */
@@ -129,7 +177,7 @@ async function runSync(config, store, options = {}) {
129
177
  role: view.role,
130
178
  objectTypeId
131
179
  })]);
132
- const actions = reconcile(source, mirror, { composite: isCompositeEntity(config, view.entityKey) }).filter((a) => !(options.sample && a.kind === "archive"));
180
+ const actions = reconcile(source, mirror, { memberships: view.concept === "contact" }).filter((a) => !(options.sample && isRemovalAction(a)));
133
181
  const persistActions = await resolveActions(store, actions);
134
182
  await store.persist({
135
183
  concept: view.concept,
@@ -217,6 +265,12 @@ Object.defineProperty(exports, "computeSampleWarnings", {
217
265
  return computeSampleWarnings;
218
266
  }
219
267
  });
268
+ Object.defineProperty(exports, "isRemovalAction", {
269
+ enumerable: true,
270
+ get: function() {
271
+ return isRemovalAction;
272
+ }
273
+ });
220
274
  Object.defineProperty(exports, "previewSync", {
221
275
  enumerable: true,
222
276
  get: function() {
@@ -229,6 +283,12 @@ Object.defineProperty(exports, "reconcile", {
229
283
  return reconcile;
230
284
  }
231
285
  });
286
+ Object.defineProperty(exports, "resolveActions", {
287
+ enumerable: true,
288
+ get: function() {
289
+ return resolveActions;
290
+ }
291
+ });
232
292
  Object.defineProperty(exports, "runSync", {
233
293
  enumerable: true,
234
294
  get: function() {
@@ -30,77 +30,112 @@ interface SourceRow {
30
30
  /** An existing mirror row, as far as reconciliation needs to know. */
31
31
  interface MirrorRecord {
32
32
  archivedAt: Date | null;
33
- /** Host-side company key; only consulted in composite (join-table) mode. */
33
+ /** Host-side key of the row's primary company (its `company_source_ref`). */
34
34
  companyRef: string | null;
35
+ /**
36
+ * Host-side keys of the person's ACTIVE memberships. Only listed for
37
+ * contacts; reconciliation ends any membership absent from a full sync.
38
+ */
39
+ companyRefs?: string[];
35
40
  id: string;
36
41
  sourceRef: string;
37
42
  }
38
- type SyncAction = {
43
+ /**
44
+ * The tenants a person belongs to, as seen in this batch. Present on contact
45
+ * actions only (the `memberships` reconcile option); `companyRef` alongside it
46
+ * is the one picked as primary.
47
+ */
48
+ interface MembershipSet {
49
+ companyRefs?: string[];
50
+ }
51
+ type SyncAction = ({
39
52
  kind: "create";
40
53
  sourceRef: string;
41
54
  fields: Record<string, string | null>;
42
55
  companyRef: string | null;
43
- } | {
56
+ } & MembershipSet) | ({
44
57
  kind: "update";
45
58
  id: string;
46
59
  sourceRef: string;
47
60
  fields: Record<string, string | null>;
48
61
  companyRef: string | null;
49
- } | {
62
+ } & MembershipSet) | ({
50
63
  kind: "restore";
51
64
  id: string;
52
65
  sourceRef: string;
53
66
  fields: Record<string, string | null>;
54
67
  companyRef: string | null;
55
- } | {
68
+ } & MembershipSet) | {
56
69
  kind: "archive";
57
70
  id: string;
58
71
  sourceRef: string;
72
+ } | {
73
+ kind: "endMembership";
74
+ id: string;
75
+ sourceRef: string;
76
+ companyRef: string;
59
77
  };
78
+ /** A membership with its host tenant key resolved to a `crm_companies.id`. */
79
+ interface ResolvedMembership {
80
+ companyId: string | null;
81
+ companyRef: string;
82
+ }
83
+ interface ResolvedMembershipSet {
84
+ memberships?: ResolvedMembership[];
85
+ }
60
86
  /** A persist action with the company ref resolved to a `crm_companies.id`. */
61
- type PersistAction = {
87
+ type PersistAction = ({
62
88
  kind: "create";
63
89
  sourceRef: string;
64
90
  fields: Record<string, string | null>;
65
91
  companyId: string | null;
66
92
  companySourceRef: string | null;
67
- } | {
93
+ } & ResolvedMembershipSet) | ({
68
94
  kind: "update";
69
95
  id: string;
70
96
  sourceRef: string;
71
97
  fields: Record<string, string | null>;
72
98
  companyId: string | null;
73
99
  companySourceRef: string | null;
74
- } | {
100
+ } & ResolvedMembershipSet) | ({
75
101
  kind: "restore";
76
102
  id: string;
77
103
  sourceRef: string;
78
104
  fields: Record<string, string | null>;
79
105
  companyId: string | null;
80
106
  companySourceRef: string | null;
81
- } | {
107
+ } & ResolvedMembershipSet) | {
82
108
  kind: "archive";
83
109
  id: string;
84
110
  sourceRef: string;
111
+ } | {
112
+ kind: "endMembership";
113
+ id: string;
114
+ sourceRef: string;
115
+ companyRef: string;
85
116
  };
117
+ /** Actions that remove something — only safe when the source set is complete. */
118
+ declare function isRemovalAction(action: {
119
+ kind: string;
120
+ }): boolean;
86
121
  /**
87
122
  * Diff a view's rows against the current mirror population for one entity.
88
123
  *
89
- * - source row with no mirror row → create
90
- * - source row with a live mirror row → update (overwrite identity fields)
91
- * - source row with an archived mirror → restore (re-activate + update)
92
- * - live mirror row absent from source → archive (soft delete, keep history)
93
- * - archived mirror row absent from source → no-op
124
+ * - source identity with no mirror row → create
125
+ * - source identity with a live mirror row → update (overwrite identity fields)
126
+ * - source identity with an archived mirror → restore (re-activate + update)
127
+ * - live mirror row absent from source → archive (soft delete, keep history)
128
+ * - archived mirror row absent from source → no-op
94
129
  *
95
- * Source rows are de-duplicated by the reconciliation key (first wins). In
96
- * simple mode (default) the key is `sourceRef`. In composite mode — used for
97
- * `through` (join-table) entities, where the compiled view fans a person out
98
- * into one row per membership — the key is `(sourceRef, companyRef)`, so a
99
- * person in several companies materializes one mirror row per company and a
100
- * single membership leaving archives only that one row.
130
+ * Identity is always the source primary key. A `through` (join-table) view
131
+ * fans one person out into a row per tenant; those rows are ONE person, so they
132
+ * collapse into one action. With `memberships` on (contacts), that action also
133
+ * carries every tenant ref the person was seen with, and each tenant the mirror
134
+ * still has as active but the source no longer lists yields `endMembership` —
135
+ * a person leaving one tenant ends that membership and keeps the others.
101
136
  */
102
137
  declare function reconcile(source: SourceRow[], mirror: MirrorRecord[], opts?: {
103
- composite?: boolean;
138
+ memberships?: boolean;
104
139
  }): SyncAction[];
105
140
  /** DB I/O port. The concrete store lives in the SDK/drizzle package. */
106
141
  interface SyncStore {
@@ -147,6 +182,12 @@ interface SyncResult {
147
182
  archived: number;
148
183
  };
149
184
  }
185
+ /**
186
+ * Resolve each action's host-side company refs — the primary one and, for
187
+ * contacts, every membership — to `crm_companies.id`s in one lookup. Shared by
188
+ * `runSync` and the cloud push receiver so both persist identical actions.
189
+ */
190
+ declare function resolveActions(store: SyncStore, actions: SyncAction[]): Promise<PersistAction[]>;
150
191
  /**
151
192
  * Run a full sync: compile the config's views, then reconcile + persist each,
152
193
  * companies first. Returns per-entity and total action counts.
@@ -181,4 +222,4 @@ declare function previewSync(store: SyncStore, view: CompiledView, opts: {
181
222
  sourceTable: string;
182
223
  }): Promise<PreviewResult>;
183
224
  //#endregion
184
- export { PreviewWarning as a, SyncResult as c, previewSync as d, reconcile as f, PreviewResult as i, SyncStore as l, MirrorRecord as n, SourceRow as o, runSync as p, PersistAction as r, SyncAction as s, EntitySyncResult as t, computeSampleWarnings as u };
225
+ export { PreviewWarning as a, SyncAction as c, computeSampleWarnings as d, isRemovalAction as f, runSync as g, resolveActions as h, PreviewResult as i, SyncResult as l, reconcile as m, MirrorRecord as n, ResolvedMembership as o, previewSync as p, PersistAction as r, SourceRow as s, EntitySyncResult as t, SyncStore as u };
@@ -30,77 +30,112 @@ interface SourceRow {
30
30
  /** An existing mirror row, as far as reconciliation needs to know. */
31
31
  interface MirrorRecord {
32
32
  archivedAt: Date | null;
33
- /** Host-side company key; only consulted in composite (join-table) mode. */
33
+ /** Host-side key of the row's primary company (its `company_source_ref`). */
34
34
  companyRef: string | null;
35
+ /**
36
+ * Host-side keys of the person's ACTIVE memberships. Only listed for
37
+ * contacts; reconciliation ends any membership absent from a full sync.
38
+ */
39
+ companyRefs?: string[];
35
40
  id: string;
36
41
  sourceRef: string;
37
42
  }
38
- type SyncAction = {
43
+ /**
44
+ * The tenants a person belongs to, as seen in this batch. Present on contact
45
+ * actions only (the `memberships` reconcile option); `companyRef` alongside it
46
+ * is the one picked as primary.
47
+ */
48
+ interface MembershipSet {
49
+ companyRefs?: string[];
50
+ }
51
+ type SyncAction = ({
39
52
  kind: "create";
40
53
  sourceRef: string;
41
54
  fields: Record<string, string | null>;
42
55
  companyRef: string | null;
43
- } | {
56
+ } & MembershipSet) | ({
44
57
  kind: "update";
45
58
  id: string;
46
59
  sourceRef: string;
47
60
  fields: Record<string, string | null>;
48
61
  companyRef: string | null;
49
- } | {
62
+ } & MembershipSet) | ({
50
63
  kind: "restore";
51
64
  id: string;
52
65
  sourceRef: string;
53
66
  fields: Record<string, string | null>;
54
67
  companyRef: string | null;
55
- } | {
68
+ } & MembershipSet) | {
56
69
  kind: "archive";
57
70
  id: string;
58
71
  sourceRef: string;
72
+ } | {
73
+ kind: "endMembership";
74
+ id: string;
75
+ sourceRef: string;
76
+ companyRef: string;
59
77
  };
78
+ /** A membership with its host tenant key resolved to a `crm_companies.id`. */
79
+ interface ResolvedMembership {
80
+ companyId: string | null;
81
+ companyRef: string;
82
+ }
83
+ interface ResolvedMembershipSet {
84
+ memberships?: ResolvedMembership[];
85
+ }
60
86
  /** A persist action with the company ref resolved to a `crm_companies.id`. */
61
- type PersistAction = {
87
+ type PersistAction = ({
62
88
  kind: "create";
63
89
  sourceRef: string;
64
90
  fields: Record<string, string | null>;
65
91
  companyId: string | null;
66
92
  companySourceRef: string | null;
67
- } | {
93
+ } & ResolvedMembershipSet) | ({
68
94
  kind: "update";
69
95
  id: string;
70
96
  sourceRef: string;
71
97
  fields: Record<string, string | null>;
72
98
  companyId: string | null;
73
99
  companySourceRef: string | null;
74
- } | {
100
+ } & ResolvedMembershipSet) | ({
75
101
  kind: "restore";
76
102
  id: string;
77
103
  sourceRef: string;
78
104
  fields: Record<string, string | null>;
79
105
  companyId: string | null;
80
106
  companySourceRef: string | null;
81
- } | {
107
+ } & ResolvedMembershipSet) | {
82
108
  kind: "archive";
83
109
  id: string;
84
110
  sourceRef: string;
111
+ } | {
112
+ kind: "endMembership";
113
+ id: string;
114
+ sourceRef: string;
115
+ companyRef: string;
85
116
  };
117
+ /** Actions that remove something — only safe when the source set is complete. */
118
+ declare function isRemovalAction(action: {
119
+ kind: string;
120
+ }): boolean;
86
121
  /**
87
122
  * Diff a view's rows against the current mirror population for one entity.
88
123
  *
89
- * - source row with no mirror row → create
90
- * - source row with a live mirror row → update (overwrite identity fields)
91
- * - source row with an archived mirror → restore (re-activate + update)
92
- * - live mirror row absent from source → archive (soft delete, keep history)
93
- * - archived mirror row absent from source → no-op
124
+ * - source identity with no mirror row → create
125
+ * - source identity with a live mirror row → update (overwrite identity fields)
126
+ * - source identity with an archived mirror → restore (re-activate + update)
127
+ * - live mirror row absent from source → archive (soft delete, keep history)
128
+ * - archived mirror row absent from source → no-op
94
129
  *
95
- * Source rows are de-duplicated by the reconciliation key (first wins). In
96
- * simple mode (default) the key is `sourceRef`. In composite mode — used for
97
- * `through` (join-table) entities, where the compiled view fans a person out
98
- * into one row per membership — the key is `(sourceRef, companyRef)`, so a
99
- * person in several companies materializes one mirror row per company and a
100
- * single membership leaving archives only that one row.
130
+ * Identity is always the source primary key. A `through` (join-table) view
131
+ * fans one person out into a row per tenant; those rows are ONE person, so they
132
+ * collapse into one action. With `memberships` on (contacts), that action also
133
+ * carries every tenant ref the person was seen with, and each tenant the mirror
134
+ * still has as active but the source no longer lists yields `endMembership` —
135
+ * a person leaving one tenant ends that membership and keeps the others.
101
136
  */
102
137
  declare function reconcile(source: SourceRow[], mirror: MirrorRecord[], opts?: {
103
- composite?: boolean;
138
+ memberships?: boolean;
104
139
  }): SyncAction[];
105
140
  /** DB I/O port. The concrete store lives in the SDK/drizzle package. */
106
141
  interface SyncStore {
@@ -147,6 +182,12 @@ interface SyncResult {
147
182
  archived: number;
148
183
  };
149
184
  }
185
+ /**
186
+ * Resolve each action's host-side company refs — the primary one and, for
187
+ * contacts, every membership — to `crm_companies.id`s in one lookup. Shared by
188
+ * `runSync` and the cloud push receiver so both persist identical actions.
189
+ */
190
+ declare function resolveActions(store: SyncStore, actions: SyncAction[]): Promise<PersistAction[]>;
150
191
  /**
151
192
  * Run a full sync: compile the config's views, then reconcile + persist each,
152
193
  * companies first. Returns per-entity and total action counts.
@@ -181,4 +222,4 @@ declare function previewSync(store: SyncStore, view: CompiledView, opts: {
181
222
  sourceTable: string;
182
223
  }): Promise<PreviewResult>;
183
224
  //#endregion
184
- export { PreviewWarning as a, SyncResult as c, previewSync as d, reconcile as f, PreviewResult as i, SyncStore as l, MirrorRecord as n, SourceRow as o, runSync as p, PersistAction as r, SyncAction as s, EntitySyncResult as t, computeSampleWarnings as u };
225
+ export { PreviewWarning as a, SyncAction as c, computeSampleWarnings as d, isRemovalAction as f, runSync as g, resolveActions as h, PreviewResult as i, SyncResult as l, reconcile as m, MirrorRecord as n, ResolvedMembership as o, previewSync as p, PersistAction as r, SourceRow as s, EntitySyncResult as t, SyncStore as u };