@clivly/core 0.13.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 +2 -2
- package/dist/adapter.d.cts +13 -0
- package/dist/adapter.d.mts +13 -0
- package/dist/index.cjs +2 -0
- package/dist/index.d.cts +2 -2
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +2 -2
- package/dist/sync-engine.cjs +121 -71
- package/dist/sync-engine.d.cts +62 -21
- package/dist/sync-engine.d.mts +62 -21
- package/dist/sync-engine.mjs +120 -72
- package/package.json +1 -1
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
|
|
package/dist/adapter.d.cts
CHANGED
|
@@ -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/adapter.d.mts
CHANGED
|
@@ -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 };
|
package/dist/sync-engine.cjs
CHANGED
|
@@ -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
|
|
8
|
-
* - source
|
|
9
|
-
* - source
|
|
10
|
-
* - live mirror row absent from source
|
|
11
|
-
* - archived mirror row absent from source
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
|
22
|
-
const
|
|
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
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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)
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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, {
|
|
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;
|
package/dist/sync-engine.d.cts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
90
|
-
* - source
|
|
91
|
-
* - source
|
|
92
|
-
* - live mirror row absent from source
|
|
93
|
-
* - archived mirror row absent from source
|
|
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
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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
|
-
|
|
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.
|
package/dist/sync-engine.d.mts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
90
|
-
* - source
|
|
91
|
-
* - source
|
|
92
|
-
* - live mirror row absent from source
|
|
93
|
-
* - archived mirror row absent from source
|
|
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
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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
|
-
|
|
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.
|
package/dist/sync-engine.mjs
CHANGED
|
@@ -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
|
|
7
|
-
* - source
|
|
8
|
-
* - source
|
|
9
|
-
* - live mirror row absent from source
|
|
10
|
-
* - archived mirror row absent from source
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
21
|
-
const
|
|
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
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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)
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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, {
|
|
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 };
|