@clivly/core 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.
package/README.md CHANGED
@@ -48,7 +48,7 @@ client.
48
48
  | Entry | Contents |
49
49
  | --- | --- |
50
50
  | `@clivly/core` | The re-exported surface: contracts, entity config, mapping form, sync engine, view compiler, projections, shared types |
51
- | `@clivly/core/adapter` | `CRMAdapter` — the interface every ORM implementation fulfils (org-scoped; contacts, companies, deals, tasks, activities) |
51
+ | `@clivly/core/adapter` | `CRMAdapter` — the interface every ORM implementation fulfils (org-scoped; contacts, companies, deals, tasks, activities). List reads are keyset-paged: `listContactsPage`, `listCompaniesPage`, `listDealsPage` and `listTasksPage` take a `PageRequest` and return a `Page` |
52
52
  | `@clivly/core/auth-adapter` | `ClivlyAuthAdapter`, `ClivlyUser`, `authCustom()`, `MembershipResolver`, `CrmMembership` — pluggable host-app identity + the `crm_members` authorization layer |
53
53
  | `@clivly/core/entity-config` | `defineClivlyConfig`, `validateEntitiesConfig`, `entitiesConfigSchema`, `CANONICAL_FIELDS`, `CRM_CONCEPTS`, `ClivlyConfigError`, and the config types |
54
54
  | `@clivly/core/types` | Shared domain types (`Contact`, `Company`, `Deal`, `Task`, `Note`, `Activity`, inputs, filters, `CrmRole`) |
@@ -58,7 +58,7 @@ client.
58
58
  | `@clivly/core/mapping-form` | `buildFilterFromDiscriminator`, `buildRelationships`, `relationshipViaSignature` — mapping-UI helpers |
59
59
  | `@clivly/core/mapping-score` | Scoring helpers for ranking candidate mappings |
60
60
  | `@clivly/core/entity-heuristics` | Table-name heuristics that guess which table holds contacts/companies |
61
- | `@clivly/core/sync-engine` | `reconcile`, `runSync`, `SyncStore` and friends — the mirror reconciliation engine |
61
+ | `@clivly/core/sync-engine` | `reconcile`, `runSync`, `resolveActions`, `isRemovalAction`, `SyncStore` and friends — the mirror reconciliation engine. Identity is the source primary key; with `reconcile(..., { memberships: true })` (contacts) a person's join-table rows collapse into one action carrying every tenant ref, and a tenant they left yields an `endMembership` action. Apply removals (`archive`, `endMembership`) only for a complete source set |
62
62
  | `@clivly/core/integration-state` | `INSTALL_COMMAND` and the shared onboarding state machine |
63
63
  | `@clivly/core/env-file` | `envFileFor(framework)` — the conventional env filename per framework |
64
64
 
@@ -76,6 +76,19 @@ export interface CRMAdapter {
76
76
  listDealsPage(orgId: string, filters?: {
77
77
  source?: RecordSource;
78
78
  }, page?: PageRequest): Promise<Page<Deal>>;
79
+ /**
80
+ * One page of tasks, newest-created first. See
81
+ * {@link CRMAdapter.listCompaniesPage}.
82
+ *
83
+ * `filters.entityType`/`entityId` match the task's own polymorphic-link
84
+ * columns. `filters.additionalIds`, when given, extends that match with an
85
+ * explicit id allowlist — `services/tasks.ts`'s `listTasks` uses it to fold
86
+ * in tasks linked only via `crm_record_links` (the multi-link table),
87
+ * without falling back to an unbounded read.
88
+ */
89
+ listTasksPage(orgId: string, filters?: TaskFilters & {
90
+ additionalIds?: string[];
91
+ }, page?: PageRequest): Promise<Page<Task>>;
79
92
  /**
80
93
  * Move a deal to `stage` and reorder it directly after `afterId` (null = top
81
94
  * of the column). The adapter computes the fractional `position` from the
@@ -76,6 +76,19 @@ export interface CRMAdapter {
76
76
  listDealsPage(orgId: string, filters?: {
77
77
  source?: RecordSource;
78
78
  }, page?: PageRequest): Promise<Page<Deal>>;
79
+ /**
80
+ * One page of tasks, newest-created first. See
81
+ * {@link CRMAdapter.listCompaniesPage}.
82
+ *
83
+ * `filters.entityType`/`entityId` match the task's own polymorphic-link
84
+ * columns. `filters.additionalIds`, when given, extends that match with an
85
+ * explicit id allowlist — `services/tasks.ts`'s `listTasks` uses it to fold
86
+ * in tasks linked only via `crm_record_links` (the multi-link table),
87
+ * without falling back to an unbounded read.
88
+ */
89
+ listTasksPage(orgId: string, filters?: TaskFilters & {
90
+ additionalIds?: string[];
91
+ }, page?: PageRequest): Promise<Page<Task>>;
79
92
  /**
80
93
  * Move a deal to `stage` and reorder it directly after `afterId` (null = top
81
94
  * of the column). The adapter computes the fractional `position` from the
package/dist/index.cjs CHANGED
@@ -291,6 +291,7 @@ exports.hostOwnedFieldMessage = hostOwnedFieldMessage;
291
291
  exports.identityRefs = require_projection.identityRefs;
292
292
  exports.isClosedStageType = require_types.isClosedStageType;
293
293
  exports.isHostOwned = isHostOwned;
294
+ exports.isRemovalAction = require_sync_engine.isRemovalAction;
294
295
  exports.joinFansOut = require_entity_config.joinFansOut;
295
296
  exports.mappingsToEntitiesConfig = require_mappings_config.mappingsToEntitiesConfig;
296
297
  exports.matchesCondition = matchesCondition;
@@ -306,6 +307,7 @@ exports.relationshipSchema = require_entity_config.relationshipSchema;
306
307
  exports.relationshipViaSignature = require_mapping_form.relationshipViaSignature;
307
308
  exports.relationshipsSchema = require_entity_config.relationshipsSchema;
308
309
  exports.requiredFieldsFor = require_entity_config.requiredFieldsFor;
310
+ exports.resolveActions = require_sync_engine.resolveActions;
309
311
  exports.runSync = require_sync_engine.runSync;
310
312
  exports.slugify = slugify;
311
313
  exports.toObjectTypeSlug = toObjectTypeSlug;
package/dist/index.d.cts CHANGED
@@ -6,7 +6,7 @@ import { ConnectionLiveness, IntegrationFacts, IntegrationState, IntegrationStat
6
6
  import { RelationshipCandidate, buildFilterFromDiscriminator, buildRelationships, relationshipViaSignature } from "./mapping-form.cjs";
7
7
  import { EntityMappingRow, mappingsToEntitiesConfig } from "./mappings-config.cjs";
8
8
  import { CompileOptions, CompiledView, DEFAULT_VIEW_PREFIX, ExplainResult, compileEntityView, compileEntityViews, explainEntitiesConfig } from "./view-compiler.cjs";
9
- import { EntitySyncResult, MirrorRecord, PersistAction, SourceRow, SyncAction, SyncResult, SyncStore, reconcile, runSync } from "./sync-engine.cjs";
9
+ import { EntitySyncResult, MirrorRecord, PersistAction, ResolvedMembership, SourceRow, SyncAction, SyncResult, SyncStore, isRemovalAction, reconcile, resolveActions, runSync } from "./sync-engine.cjs";
10
10
  //#region src/deal-value.d.ts
11
11
  /**
12
12
  * Value derived as a flat fee plus a per-seat price that depends on the plan.
@@ -333,4 +333,4 @@ export declare function matchesCondition(condition: Condition, fields: Record<st
333
333
  */
334
334
  export declare function evaluateStage(rules: EvaluableRule[], fields: Record<string, string | null>, mappedFields: Set<string>): string | null;
335
335
  //#endregion
336
- export { type Activity, type ActivityType, BASE_REF, CANONICAL_FIELDS, type CRMAdapter, CRM_CONCEPTS, type ClivlyAuthAdapter, ClivlyConfigError, type ClivlyEntitiesConfig, type ClivlyEntityConfig, type ClivlyUser, type Company, type CompanyFilters, type CompileOptions, type CompiledView, type Condition, type ConditionOp, type ConfigValidationError, type ConfigValidationResult, type ConnectionLiveness, type Contact, type ContactFilters, type ContactStatus, type CreateActivityInput, type CreateCompanyInput, type CreateContactInput, type CreateDealInput, type CreateTaskInput, type CrmConcept, type CrmMembership, type CrmRole, DEFAULT_VIEW_PREFIX, type Deal, type DealStage, type DiscoveredColumnMeta, type DiscoveredSchemaTable, type EntityMappingRow, type EntitySyncResult, type EvaluableRule, type ExplainResult, type FilterExpr, type FilterValue, type HostOwnedCompanyField, type HostOwnedContactField, type IntegrationFacts, type IntegrationState, type IntegrationStatus, type IntegrationStep, MATERIALIZING_CONCEPTS, type MaterializingConcept, type MembershipResolver, type MirrorRecord, type NextAction, type Note, OPEN_STAGE_TYPE, PROJECTION_KEY_PATTERN, type Page, type PageRequest, type PerSeatValueSource, type PersistAction, type PipelineConfig, type PipelineKind, type PipelineStageConfig, type ProjectionFieldBinding, type ProjectionIdentity, type ProjectionJoin, type ProjectionMapping, type ProjectionRef, REQUIRED_FIELDS, type RecordSource, type RelationshipCandidate, type RelationshipSpec, type RelationshipVia, type RelationshipsMap, type SourceRow, type SourcedRecord, type StageType, type StepId, type StepProblem, type StepState, type SyncAction, type SyncResult, type SyncStore, type Task, type TaskEntityType, type TaskFilters, type TaskPriority, type TaskStatus, type UpdateCompanyInput, type UpdateContactInput, type UpdateDealInput, type UpdateTaskInput, type ValueInterval, authCustom, buildFilterFromDiscriminator, buildRelationships, compileEntityView, compileEntityViews, defineClivlyConfig, entitiesConfigSchema, explainEntitiesConfig, filterSchema, identityRefs, isClosedStageType, mappingsToEntitiesConfig, missingRequiredFields, projectionFields, projectionSchema, reconcile, relationshipSchema, relationshipViaSignature, relationshipsSchema, requiredFieldsFor, runSync, validateEntitiesConfig };
336
+ export { type Activity, type ActivityType, BASE_REF, CANONICAL_FIELDS, type CRMAdapter, CRM_CONCEPTS, type ClivlyAuthAdapter, ClivlyConfigError, type ClivlyEntitiesConfig, type ClivlyEntityConfig, type ClivlyUser, type Company, type CompanyFilters, type CompileOptions, type CompiledView, type Condition, type ConditionOp, type ConfigValidationError, type ConfigValidationResult, type ConnectionLiveness, type Contact, type ContactFilters, type ContactStatus, type CreateActivityInput, type CreateCompanyInput, type CreateContactInput, type CreateDealInput, type CreateTaskInput, type CrmConcept, type CrmMembership, type CrmRole, DEFAULT_VIEW_PREFIX, type Deal, type DealStage, type DiscoveredColumnMeta, type DiscoveredSchemaTable, type EntityMappingRow, type EntitySyncResult, type EvaluableRule, type ExplainResult, type FilterExpr, type FilterValue, type HostOwnedCompanyField, type HostOwnedContactField, type IntegrationFacts, type IntegrationState, type IntegrationStatus, type IntegrationStep, MATERIALIZING_CONCEPTS, type MaterializingConcept, type MembershipResolver, type MirrorRecord, type NextAction, type Note, OPEN_STAGE_TYPE, PROJECTION_KEY_PATTERN, type Page, type PageRequest, type PerSeatValueSource, type PersistAction, type PipelineConfig, type PipelineKind, type PipelineStageConfig, type ProjectionFieldBinding, type ProjectionIdentity, type ProjectionJoin, type ProjectionMapping, type ProjectionRef, REQUIRED_FIELDS, type RecordSource, type RelationshipCandidate, type RelationshipSpec, type RelationshipVia, type RelationshipsMap, type ResolvedMembership, type SourceRow, type SourcedRecord, type StageType, type StepId, type StepProblem, type StepState, type SyncAction, type SyncResult, type SyncStore, type Task, type TaskEntityType, type TaskFilters, type TaskPriority, type TaskStatus, type UpdateCompanyInput, type UpdateContactInput, type UpdateDealInput, type UpdateTaskInput, type ValueInterval, authCustom, buildFilterFromDiscriminator, buildRelationships, compileEntityView, compileEntityViews, defineClivlyConfig, entitiesConfigSchema, explainEntitiesConfig, filterSchema, identityRefs, isClosedStageType, isRemovalAction, mappingsToEntitiesConfig, missingRequiredFields, projectionFields, projectionSchema, reconcile, relationshipSchema, relationshipViaSignature, relationshipsSchema, requiredFieldsFor, resolveActions, runSync, validateEntitiesConfig };
package/dist/index.d.mts CHANGED
@@ -6,7 +6,7 @@ import { ConnectionLiveness, IntegrationFacts, IntegrationState, IntegrationStat
6
6
  import { RelationshipCandidate, buildFilterFromDiscriminator, buildRelationships, relationshipViaSignature } from "./mapping-form.mjs";
7
7
  import { EntityMappingRow, mappingsToEntitiesConfig } from "./mappings-config.mjs";
8
8
  import { CompileOptions, CompiledView, DEFAULT_VIEW_PREFIX, ExplainResult, compileEntityView, compileEntityViews, explainEntitiesConfig } from "./view-compiler.mjs";
9
- import { EntitySyncResult, MirrorRecord, PersistAction, SourceRow, SyncAction, SyncResult, SyncStore, reconcile, runSync } from "./sync-engine.mjs";
9
+ import { EntitySyncResult, MirrorRecord, PersistAction, ResolvedMembership, SourceRow, SyncAction, SyncResult, SyncStore, isRemovalAction, reconcile, resolveActions, runSync } from "./sync-engine.mjs";
10
10
  //#region src/deal-value.d.ts
11
11
  /**
12
12
  * Value derived as a flat fee plus a per-seat price that depends on the plan.
@@ -333,4 +333,4 @@ export declare function matchesCondition(condition: Condition, fields: Record<st
333
333
  */
334
334
  export declare function evaluateStage(rules: EvaluableRule[], fields: Record<string, string | null>, mappedFields: Set<string>): string | null;
335
335
  //#endregion
336
- export { type Activity, type ActivityType, BASE_REF, CANONICAL_FIELDS, type CRMAdapter, CRM_CONCEPTS, type ClivlyAuthAdapter, ClivlyConfigError, type ClivlyEntitiesConfig, type ClivlyEntityConfig, type ClivlyUser, type Company, type CompanyFilters, type CompileOptions, type CompiledView, type Condition, type ConditionOp, type ConfigValidationError, type ConfigValidationResult, type ConnectionLiveness, type Contact, type ContactFilters, type ContactStatus, type CreateActivityInput, type CreateCompanyInput, type CreateContactInput, type CreateDealInput, type CreateTaskInput, type CrmConcept, type CrmMembership, type CrmRole, DEFAULT_VIEW_PREFIX, type Deal, type DealStage, type DiscoveredColumnMeta, type DiscoveredSchemaTable, type EntityMappingRow, type EntitySyncResult, type EvaluableRule, type ExplainResult, type FilterExpr, type FilterValue, type HostOwnedCompanyField, type HostOwnedContactField, type IntegrationFacts, type IntegrationState, type IntegrationStatus, type IntegrationStep, MATERIALIZING_CONCEPTS, type MaterializingConcept, type MembershipResolver, type MirrorRecord, type NextAction, type Note, OPEN_STAGE_TYPE, PROJECTION_KEY_PATTERN, type Page, type PageRequest, type PerSeatValueSource, type PersistAction, type PipelineConfig, type PipelineKind, type PipelineStageConfig, type ProjectionFieldBinding, type ProjectionIdentity, type ProjectionJoin, type ProjectionMapping, type ProjectionRef, REQUIRED_FIELDS, type RecordSource, type RelationshipCandidate, type RelationshipSpec, type RelationshipVia, type RelationshipsMap, type SourceRow, type SourcedRecord, type StageType, type StepId, type StepProblem, type StepState, type SyncAction, type SyncResult, type SyncStore, type Task, type TaskEntityType, type TaskFilters, type TaskPriority, type TaskStatus, type UpdateCompanyInput, type UpdateContactInput, type UpdateDealInput, type UpdateTaskInput, type ValueInterval, authCustom, buildFilterFromDiscriminator, buildRelationships, compileEntityView, compileEntityViews, defineClivlyConfig, entitiesConfigSchema, explainEntitiesConfig, filterSchema, identityRefs, isClosedStageType, mappingsToEntitiesConfig, missingRequiredFields, projectionFields, projectionSchema, reconcile, relationshipSchema, relationshipViaSignature, relationshipsSchema, requiredFieldsFor, runSync, validateEntitiesConfig };
336
+ export { type Activity, type ActivityType, BASE_REF, CANONICAL_FIELDS, type CRMAdapter, CRM_CONCEPTS, type ClivlyAuthAdapter, ClivlyConfigError, type ClivlyEntitiesConfig, type ClivlyEntityConfig, type ClivlyUser, type Company, type CompanyFilters, type CompileOptions, type CompiledView, type Condition, type ConditionOp, type ConfigValidationError, type ConfigValidationResult, type ConnectionLiveness, type Contact, type ContactFilters, type ContactStatus, type CreateActivityInput, type CreateCompanyInput, type CreateContactInput, type CreateDealInput, type CreateTaskInput, type CrmConcept, type CrmMembership, type CrmRole, DEFAULT_VIEW_PREFIX, type Deal, type DealStage, type DiscoveredColumnMeta, type DiscoveredSchemaTable, type EntityMappingRow, type EntitySyncResult, type EvaluableRule, type ExplainResult, type FilterExpr, type FilterValue, type HostOwnedCompanyField, type HostOwnedContactField, type IntegrationFacts, type IntegrationState, type IntegrationStatus, type IntegrationStep, MATERIALIZING_CONCEPTS, type MaterializingConcept, type MembershipResolver, type MirrorRecord, type NextAction, type Note, OPEN_STAGE_TYPE, PROJECTION_KEY_PATTERN, type Page, type PageRequest, type PerSeatValueSource, type PersistAction, type PipelineConfig, type PipelineKind, type PipelineStageConfig, type ProjectionFieldBinding, type ProjectionIdentity, type ProjectionJoin, type ProjectionMapping, type ProjectionRef, REQUIRED_FIELDS, type RecordSource, type RelationshipCandidate, type RelationshipSpec, type RelationshipVia, type RelationshipsMap, type ResolvedMembership, type SourceRow, type SourcedRecord, type StageType, type StepId, type StepProblem, type StepState, type SyncAction, type SyncResult, type SyncStore, type Task, type TaskEntityType, type TaskFilters, type TaskPriority, type TaskStatus, type UpdateCompanyInput, type UpdateContactInput, type UpdateDealInput, type UpdateTaskInput, type ValueInterval, authCustom, buildFilterFromDiscriminator, buildRelationships, compileEntityView, compileEntityViews, defineClivlyConfig, entitiesConfigSchema, explainEntitiesConfig, filterSchema, identityRefs, isClosedStageType, isRemovalAction, mappingsToEntitiesConfig, missingRequiredFields, projectionFields, projectionSchema, reconcile, relationshipSchema, relationshipViaSignature, relationshipsSchema, requiredFieldsFor, resolveActions, runSync, validateEntitiesConfig };
package/dist/index.mjs CHANGED
@@ -5,7 +5,7 @@ import { _ as validateProjection, a as MATERIALIZING_CONCEPTS, b as joinFansOut,
5
5
  import { buildFilterFromDiscriminator, buildRelationships, relationshipViaSignature } from "./mapping-form.mjs";
6
6
  import { mappingsToEntitiesConfig } from "./mappings-config.mjs";
7
7
  import { c as compileProjectionIdentity, d as compileProjectionSelect, f as quoteIdent, i as explainEntitiesConfig, l as compileProjectionJoins, n as compileEntityView, o as PREVIEW_MAX_ROWS, r as compileEntityViews, s as compileProjectionFrom, t as DEFAULT_VIEW_PREFIX, u as compileProjectionPreview } from "./view-compiler-CLvZ0wJW.mjs";
8
- import { reconcile, runSync } from "./sync-engine.mjs";
8
+ import { isRemovalAction, reconcile, resolveActions, runSync } from "./sync-engine.mjs";
9
9
  import { OPEN_STAGE_TYPE, isClosedStageType } from "./types.mjs";
10
10
  //#region src/deal-value.ts
11
11
  const MONTHS_PER_YEAR = 12;
@@ -256,4 +256,4 @@ function evaluateStage(rules, fields, mappedFields) {
256
256
  return null;
257
257
  }
258
258
  //#endregion
259
- export { BASE_REF, CANONICAL_FIELDS, CRM_CONCEPTS, ClivlyConfigError, DEFAULT_VIEW_PREFIX, MATERIALIZING_CONCEPTS, OPEN_STAGE_TYPE, PREVIEW_MAX_ROWS, PROJECTION_KEY_PATTERN, REQUIRED_FIELDS, authCustom, buildFilterFromDiscriminator, buildRelationships, columnMetaFor, compileEntityView, compileEntityViews, compileProjectionFrom, compileProjectionIdentity, compileProjectionJoins, compileProjectionPreview, compileProjectionSelect, computeDealValue, coversUniqueKey, defineClivlyConfig, entitiesConfigSchema, evaluateStage, explainEntitiesConfig, fanOutJoins, filterSchema, hasRichMeta, hostOwnedFieldMessage, identityRefs, isClosedStageType, isHostOwned, joinFansOut, mappingsToEntitiesConfig, matchesCondition, missingRequiredFields, primaryKeyColumns, projectionFields, projectionSchema, quoteIdent, reconcile, rejectedCompanyFields, rejectedContactFields, relationshipSchema, relationshipViaSignature, relationshipsSchema, requiredFieldsFor, runSync, slugify, toObjectTypeSlug, validateEntitiesConfig, validateProjection, validateProjectionFilter };
259
+ export { BASE_REF, CANONICAL_FIELDS, CRM_CONCEPTS, ClivlyConfigError, DEFAULT_VIEW_PREFIX, MATERIALIZING_CONCEPTS, OPEN_STAGE_TYPE, PREVIEW_MAX_ROWS, PROJECTION_KEY_PATTERN, REQUIRED_FIELDS, authCustom, buildFilterFromDiscriminator, buildRelationships, columnMetaFor, compileEntityView, compileEntityViews, compileProjectionFrom, compileProjectionIdentity, compileProjectionJoins, compileProjectionPreview, compileProjectionSelect, computeDealValue, coversUniqueKey, defineClivlyConfig, entitiesConfigSchema, evaluateStage, explainEntitiesConfig, fanOutJoins, filterSchema, hasRichMeta, hostOwnedFieldMessage, identityRefs, isClosedStageType, isHostOwned, isRemovalAction, joinFansOut, mappingsToEntitiesConfig, matchesCondition, missingRequiredFields, primaryKeyColumns, projectionFields, projectionSchema, quoteIdent, reconcile, rejectedCompanyFields, rejectedContactFields, relationshipSchema, relationshipViaSignature, relationshipsSchema, requiredFieldsFor, resolveActions, runSync, slugify, toObjectTypeSlug, validateEntitiesConfig, validateProjection, validateProjectionFilter };
@@ -1,68 +1,104 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_view_compiler = require("./view-compiler-DP4Fbj7-.cjs");
3
3
  //#region src/sync-engine.ts
4
+ /** Actions that remove something — only safe when the source set is complete. */
5
+ function isRemovalAction(action) {
6
+ return action.kind === "archive" || action.kind === "endMembership";
7
+ }
8
+ /** Every distinct non-null company ref in a person's rows, in arrival order. */
9
+ function collectCompanyRefs(rows) {
10
+ const refs = [];
11
+ for (const row of rows) {
12
+ const ref = row.companyRef ?? null;
13
+ if (ref !== null && !refs.includes(ref)) refs.push(ref);
14
+ }
15
+ return refs;
16
+ }
17
+ /**
18
+ * The primary company for a person: the one they already have while it is
19
+ * still one of theirs — so the displayed organisation never flips between
20
+ * syncs — otherwise the lowest ref, which is deterministic for a new person.
21
+ */
22
+ function pickPrimaryRef(refs, current) {
23
+ if (current !== null && refs.includes(current)) return current;
24
+ return [...refs].sort()[0] ?? null;
25
+ }
26
+ /**
27
+ * The mirror row that represents a source identity. Normally there is exactly
28
+ * one; before contact consolidation has run there can be several (one per
29
+ * tenant), and the live, earliest-listed one stands for the person while the
30
+ * rest are left alone for the consolidation script to fold in.
31
+ */
32
+ function canonicalMirror(rows) {
33
+ return rows.find((row) => !row.archivedAt) ?? rows[0];
34
+ }
4
35
  /**
5
36
  * Diff a view's rows against the current mirror population for one entity.
6
37
  *
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
38
+ * - source identity with no mirror row → create
39
+ * - source identity with a live mirror row → update (overwrite identity fields)
40
+ * - source identity with an archived mirror → restore (re-activate + update)
41
+ * - live mirror row absent from source → archive (soft delete, keep history)
42
+ * - archived mirror row absent from source → no-op
12
43
  *
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.
44
+ * Identity is always the source primary key. A `through` (join-table) view
45
+ * fans one person out into a row per tenant; those rows are ONE person, so they
46
+ * collapse into one action. With `memberships` on (contacts), that action also
47
+ * carries every tenant ref the person was seen with, and each tenant the mirror
48
+ * still has as active but the source no longer lists yields `endMembership` —
49
+ * a person leaving one tenant ends that membership and keeps the others.
19
50
  */
20
51
  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();
52
+ const rowsByRef = groupBySourceRef(source);
53
+ const mirrorByRef = groupBySourceRef(mirror);
33
54
  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
- });
55
+ for (const [sourceRef, rows] of rowsByRef) {
56
+ const existing = canonicalMirror(mirrorByRef.get(sourceRef) ?? []);
57
+ actions.push(...personActions(sourceRef, rows, existing, opts));
58
+ }
59
+ for (const m of mirror) if (!(rowsByRef.has(m.sourceRef) || m.archivedAt)) actions.push({
60
+ kind: "archive",
61
+ id: m.id,
62
+ sourceRef: m.sourceRef
63
+ });
64
+ return actions;
65
+ }
66
+ function groupBySourceRef(items) {
67
+ const out = /* @__PURE__ */ new Map();
68
+ for (const item of items) {
69
+ const list = out.get(item.sourceRef);
70
+ if (list) list.push(item);
71
+ else out.set(item.sourceRef, [item]);
59
72
  }
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
73
+ return out;
74
+ }
75
+ /** The create/update/restore for one present person, plus any ended tenants. */
76
+ function personActions(sourceRef, rows, existing, opts) {
77
+ const first = rows[0];
78
+ const refs = collectCompanyRefs(rows);
79
+ const companyRef = opts.memberships ? pickPrimaryRef(refs, existing?.companyRef ?? null) : first.companyRef ?? null;
80
+ const membershipSet = opts.memberships ? { companyRefs: refs } : {};
81
+ if (!existing) return [{
82
+ kind: "create",
83
+ sourceRef,
84
+ fields: first.fields,
85
+ companyRef,
86
+ ...membershipSet
87
+ }];
88
+ const actions = [{
89
+ kind: existing.archivedAt ? "restore" : "update",
90
+ id: existing.id,
91
+ sourceRef,
92
+ fields: first.fields,
93
+ companyRef,
94
+ ...membershipSet
95
+ }];
96
+ if (opts.memberships) {
97
+ for (const held of existing.companyRefs ?? []) if (!refs.includes(held)) actions.push({
98
+ kind: "endMembership",
99
+ id: existing.id,
100
+ sourceRef,
101
+ companyRef: held
66
102
  });
67
103
  }
68
104
  return actions;
@@ -75,20 +111,40 @@ const CONCEPT_ORDER = {
75
111
  function syncOrder(a, b) {
76
112
  return (CONCEPT_ORDER[a.concept] ?? 99) - (CONCEPT_ORDER[b.concept] ?? 99);
77
113
  }
114
+ /**
115
+ * Resolve each action's host-side company refs — the primary one and, for
116
+ * contacts, every membership — to `crm_companies.id`s in one lookup. Shared by
117
+ * `runSync` and the cloud push receiver so both persist identical actions.
118
+ */
78
119
  async function resolveActions(store, actions) {
79
120
  const refs = /* @__PURE__ */ new Set();
80
- for (const action of actions) if (action.kind !== "archive" && action.companyRef) refs.add(action.companyRef);
121
+ for (const action of actions) {
122
+ if (isRemovalAction(action) || !("fields" in action)) continue;
123
+ if (action.companyRef) refs.add(action.companyRef);
124
+ for (const ref of action.companyRefs ?? []) refs.add(ref);
125
+ }
81
126
  const resolved = refs.size ? await store.resolveCompanyRefs([...refs]) : /* @__PURE__ */ new Map();
82
127
  return actions.map((action) => {
83
- if (action.kind === "archive") return action;
128
+ if (action.kind === "archive" || action.kind === "endMembership") return action;
84
129
  const companyId = action.companyRef ? resolved.get(action.companyRef) ?? null : null;
85
- return {
86
- kind: action.kind,
87
- id: "id" in action ? action.id : "",
130
+ const memberships = action.companyRefs?.map((companyRef) => ({
131
+ companyRef,
132
+ companyId: resolved.get(companyRef) ?? null
133
+ }));
134
+ const base = {
88
135
  sourceRef: action.sourceRef,
89
136
  fields: action.fields,
90
137
  companyId,
91
- companySourceRef: action.companyRef ?? null
138
+ companySourceRef: action.companyRef ?? null,
139
+ ...memberships ? { memberships } : {}
140
+ };
141
+ return action.kind === "create" ? {
142
+ kind: "create",
143
+ ...base
144
+ } : {
145
+ kind: action.kind,
146
+ id: action.id,
147
+ ...base
92
148
  };
93
149
  });
94
150
  }
@@ -97,19 +153,11 @@ function emptyTally() {
97
153
  create: 0,
98
154
  update: 0,
99
155
  restore: 0,
100
- archive: 0
156
+ archive: 0,
157
+ endMembership: 0
101
158
  };
102
159
  }
103
160
  /**
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
161
  * Run a full sync: compile the config's views, then reconcile + persist each,
114
162
  * companies first. Returns per-entity and total action counts.
115
163
  */
@@ -130,7 +178,7 @@ async function runSync(config, store, options = {}) {
130
178
  role: view.role,
131
179
  objectTypeId
132
180
  })]);
133
- const actions = reconcile(source, mirror, { composite: isCompositeEntity(config, view.entityKey) }).filter((a) => !(options.sample && a.kind === "archive"));
181
+ const actions = reconcile(source, mirror, { memberships: view.concept === "contact" }).filter((a) => !(options.sample && isRemovalAction(a)));
134
182
  const persistActions = await resolveActions(store, actions);
135
183
  await store.persist({
136
184
  concept: view.concept,
@@ -213,6 +261,8 @@ async function previewSync(store, view, opts) {
213
261
  }
214
262
  //#endregion
215
263
  exports.computeSampleWarnings = computeSampleWarnings;
264
+ exports.isRemovalAction = isRemovalAction;
216
265
  exports.previewSync = previewSync;
217
266
  exports.reconcile = reconcile;
267
+ exports.resolveActions = resolveActions;
218
268
  exports.runSync = runSync;
@@ -30,77 +30,112 @@ export interface SourceRow {
30
30
  /** An existing mirror row, as far as reconciliation needs to know. */
31
31
  export 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
- export 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
+ export 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
+ export 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
- export type PersistAction = {
87
+ export 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
+ export 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
  export 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
  export interface SyncStore {
@@ -147,6 +182,12 @@ export 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
+ export 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.
@@ -30,77 +30,112 @@ export interface SourceRow {
30
30
  /** An existing mirror row, as far as reconciliation needs to know. */
31
31
  export 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
- export 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
+ export 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
+ export 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
- export type PersistAction = {
87
+ export 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
+ export 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
  export 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
  export interface SyncStore {
@@ -147,6 +182,12 @@ export 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
+ export 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.
@@ -1,67 +1,103 @@
1
1
  import { r as compileEntityViews } from "./view-compiler-CLvZ0wJW.mjs";
2
2
  //#region 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,
@@ -211,4 +259,4 @@ async function previewSync(store, view, opts) {
211
259
  };
212
260
  }
213
261
  //#endregion
214
- export { computeSampleWarnings, previewSync, reconcile, runSync };
262
+ export { computeSampleWarnings, isRemovalAction, previewSync, reconcile, resolveActions, runSync };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clivly/core",
3
- "version": "0.12.0",
3
+ "version": "0.14.1",
4
4
  "private": false,
5
5
  "description": "ORM/auth-agnostic CRM adapter contracts and shared domain types for Clivly.",
6
6
  "license": "MIT",