@clivly/core 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/adapter.d.cts +27 -0
- package/dist/adapter.d.mts +27 -0
- package/dist/index.cjs +14 -0
- package/dist/index.d.cts +5 -3
- package/dist/index.d.mts +5 -3
- package/dist/index.mjs +14 -2
- package/dist/integration-state.cjs +1 -0
- package/dist/integration-state.d.cts +1 -0
- package/dist/integration-state.d.mts +1 -0
- package/dist/integration-state.mjs +1 -1
- 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/dist/types.d.cts +35 -0
- package/dist/types.d.mts +35 -0
- 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`; `countContacts` returns how many contacts match the same `ContactFilters` |
|
|
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
|
@@ -19,6 +19,20 @@ export interface CRMAdapter {
|
|
|
19
19
|
archiveContact(orgId: string, id: string): Promise<Contact>;
|
|
20
20
|
archiveTask(orgId: string, id: string): Promise<Task>;
|
|
21
21
|
completeTask(orgId: string, id: string): Promise<Task>;
|
|
22
|
+
/**
|
|
23
|
+
* How many contacts match `filters`, ignoring pagination entirely.
|
|
24
|
+
*
|
|
25
|
+
* Exists so a caller can state the size of a selection before acting on it —
|
|
26
|
+
* a bulk send has to say "this will go to N contacts", and the paged read
|
|
27
|
+
* cannot answer that: it knows only whether *another* page exists, never how
|
|
28
|
+
* many rows are behind it.
|
|
29
|
+
*
|
|
30
|
+
* Takes the same `ContactFilters` as {@link CRMAdapter.listContactsPage} and
|
|
31
|
+
* applies exactly the same predicates, so the count and the list can never
|
|
32
|
+
* disagree about what matches. It returns a number rather than rows, so it
|
|
33
|
+
* stays bounded however large the match is.
|
|
34
|
+
*/
|
|
35
|
+
countContacts(orgId: string, filters?: ContactFilters): Promise<number>;
|
|
22
36
|
createActivity(orgId: string, data: CreateActivityInput): Promise<Activity>;
|
|
23
37
|
createCompany(orgId: string, data: CreateCompanyInput): Promise<Company>;
|
|
24
38
|
createContact(orgId: string, data: CreateContactInput): Promise<Contact>;
|
|
@@ -76,6 +90,19 @@ export interface CRMAdapter {
|
|
|
76
90
|
listDealsPage(orgId: string, filters?: {
|
|
77
91
|
source?: RecordSource;
|
|
78
92
|
}, page?: PageRequest): Promise<Page<Deal>>;
|
|
93
|
+
/**
|
|
94
|
+
* One page of tasks, newest-created first. See
|
|
95
|
+
* {@link CRMAdapter.listCompaniesPage}.
|
|
96
|
+
*
|
|
97
|
+
* `filters.entityType`/`entityId` match the task's own polymorphic-link
|
|
98
|
+
* columns. `filters.additionalIds`, when given, extends that match with an
|
|
99
|
+
* explicit id allowlist — `services/tasks.ts`'s `listTasks` uses it to fold
|
|
100
|
+
* in tasks linked only via `crm_record_links` (the multi-link table),
|
|
101
|
+
* without falling back to an unbounded read.
|
|
102
|
+
*/
|
|
103
|
+
listTasksPage(orgId: string, filters?: TaskFilters & {
|
|
104
|
+
additionalIds?: string[];
|
|
105
|
+
}, page?: PageRequest): Promise<Page<Task>>;
|
|
79
106
|
/**
|
|
80
107
|
* Move a deal to `stage` and reorder it directly after `afterId` (null = top
|
|
81
108
|
* of the column). The adapter computes the fractional `position` from the
|
package/dist/adapter.d.mts
CHANGED
|
@@ -19,6 +19,20 @@ export interface CRMAdapter {
|
|
|
19
19
|
archiveContact(orgId: string, id: string): Promise<Contact>;
|
|
20
20
|
archiveTask(orgId: string, id: string): Promise<Task>;
|
|
21
21
|
completeTask(orgId: string, id: string): Promise<Task>;
|
|
22
|
+
/**
|
|
23
|
+
* How many contacts match `filters`, ignoring pagination entirely.
|
|
24
|
+
*
|
|
25
|
+
* Exists so a caller can state the size of a selection before acting on it —
|
|
26
|
+
* a bulk send has to say "this will go to N contacts", and the paged read
|
|
27
|
+
* cannot answer that: it knows only whether *another* page exists, never how
|
|
28
|
+
* many rows are behind it.
|
|
29
|
+
*
|
|
30
|
+
* Takes the same `ContactFilters` as {@link CRMAdapter.listContactsPage} and
|
|
31
|
+
* applies exactly the same predicates, so the count and the list can never
|
|
32
|
+
* disagree about what matches. It returns a number rather than rows, so it
|
|
33
|
+
* stays bounded however large the match is.
|
|
34
|
+
*/
|
|
35
|
+
countContacts(orgId: string, filters?: ContactFilters): Promise<number>;
|
|
22
36
|
createActivity(orgId: string, data: CreateActivityInput): Promise<Activity>;
|
|
23
37
|
createCompany(orgId: string, data: CreateCompanyInput): Promise<Company>;
|
|
24
38
|
createContact(orgId: string, data: CreateContactInput): Promise<Contact>;
|
|
@@ -76,6 +90,19 @@ export interface CRMAdapter {
|
|
|
76
90
|
listDealsPage(orgId: string, filters?: {
|
|
77
91
|
source?: RecordSource;
|
|
78
92
|
}, page?: PageRequest): Promise<Page<Deal>>;
|
|
93
|
+
/**
|
|
94
|
+
* One page of tasks, newest-created first. See
|
|
95
|
+
* {@link CRMAdapter.listCompaniesPage}.
|
|
96
|
+
*
|
|
97
|
+
* `filters.entityType`/`entityId` match the task's own polymorphic-link
|
|
98
|
+
* columns. `filters.additionalIds`, when given, extends that match with an
|
|
99
|
+
* explicit id allowlist — `services/tasks.ts`'s `listTasks` uses it to fold
|
|
100
|
+
* in tasks linked only via `crm_record_links` (the multi-link table),
|
|
101
|
+
* without falling back to an unbounded read.
|
|
102
|
+
*/
|
|
103
|
+
listTasksPage(orgId: string, filters?: TaskFilters & {
|
|
104
|
+
additionalIds?: string[];
|
|
105
|
+
}, page?: PageRequest): Promise<Page<Task>>;
|
|
79
106
|
/**
|
|
80
107
|
* Move a deal to `stage` and reorder it directly after `afterId` (null = top
|
|
81
108
|
* of the column). The adapter computes the fractional `position` from the
|
package/dist/index.cjs
CHANGED
|
@@ -33,6 +33,18 @@ function computeDealValue(source, fields, mappedFields, stageType) {
|
|
|
33
33
|
const seats = Number(rawSeats);
|
|
34
34
|
if (!Number.isSafeInteger(seats)) return null;
|
|
35
35
|
const monthly = (source.baseFeeCents ?? 0) + seats * unitPriceCents;
|
|
36
|
+
if (!Number.isSafeInteger(monthly) || monthly < 0) return null;
|
|
37
|
+
return normalizePrice(monthly, source, fields, mappedFields);
|
|
38
|
+
}
|
|
39
|
+
/** Preserve old configurations while making new price bases explicit. */
|
|
40
|
+
function normalizePrice(monthly, source, fields, mappedFields) {
|
|
41
|
+
if (source.priceBasis === "monthly") return monthly;
|
|
42
|
+
if (source.priceBasis === "billing_interval") {
|
|
43
|
+
const interval = source.intervalField && mappedFields.has(source.intervalField) ? fields[source.intervalField]?.trim().toLowerCase() : null;
|
|
44
|
+
if (interval === "month" || interval === "monthly") return monthly;
|
|
45
|
+
if (interval === "year" || interval === "annual" || interval === "annually" || interval === "yearly") return Math.round(monthly / MONTHS_PER_YEAR);
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
36
48
|
return source.intervalField !== void 0 && mappedFields.has(source.intervalField) && fields[source.intervalField] === "year" ? Math.round(monthly / MONTHS_PER_YEAR) : monthly;
|
|
37
49
|
}
|
|
38
50
|
//#endregion
|
|
@@ -291,6 +303,7 @@ exports.hostOwnedFieldMessage = hostOwnedFieldMessage;
|
|
|
291
303
|
exports.identityRefs = require_projection.identityRefs;
|
|
292
304
|
exports.isClosedStageType = require_types.isClosedStageType;
|
|
293
305
|
exports.isHostOwned = isHostOwned;
|
|
306
|
+
exports.isRemovalAction = require_sync_engine.isRemovalAction;
|
|
294
307
|
exports.joinFansOut = require_entity_config.joinFansOut;
|
|
295
308
|
exports.mappingsToEntitiesConfig = require_mappings_config.mappingsToEntitiesConfig;
|
|
296
309
|
exports.matchesCondition = matchesCondition;
|
|
@@ -306,6 +319,7 @@ exports.relationshipSchema = require_entity_config.relationshipSchema;
|
|
|
306
319
|
exports.relationshipViaSignature = require_mapping_form.relationshipViaSignature;
|
|
307
320
|
exports.relationshipsSchema = require_entity_config.relationshipsSchema;
|
|
308
321
|
exports.requiredFieldsFor = require_entity_config.requiredFieldsFor;
|
|
322
|
+
exports.resolveActions = require_sync_engine.resolveActions;
|
|
309
323
|
exports.runSync = require_sync_engine.runSync;
|
|
310
324
|
exports.slugify = slugify;
|
|
311
325
|
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.
|
|
@@ -17,7 +17,7 @@ import { EntitySyncResult, MirrorRecord, PersistAction, SourceRow, SyncAction, S
|
|
|
17
17
|
* D8 made for stage-rule conditions.
|
|
18
18
|
*/
|
|
19
19
|
interface PerSeatValueSource {
|
|
20
|
-
/** Flat
|
|
20
|
+
/** Flat fee in cents, using the same price basis as the plan prices. */
|
|
21
21
|
baseFeeCents?: number;
|
|
22
22
|
/** Mapped column holding "month" or "year". Absent means always monthly. */
|
|
23
23
|
intervalField?: string;
|
|
@@ -26,6 +26,8 @@ interface PerSeatValueSource {
|
|
|
26
26
|
planField: string;
|
|
27
27
|
/** Unit price in cents, keyed by the value found in `planField`. */
|
|
28
28
|
plans: Record<string, number>;
|
|
29
|
+
/** Monthly rates or amounts charged each billing period. Omitted preserves legacy behaviour. */
|
|
30
|
+
priceBasis?: "monthly" | "billing_interval";
|
|
29
31
|
/** Mapped column holding the seat count. */
|
|
30
32
|
seatField: string;
|
|
31
33
|
/** Stage types whose deals are worth nothing (usually `["lost"]`). */
|
|
@@ -333,4 +335,4 @@ export declare function matchesCondition(condition: Condition, fields: Record<st
|
|
|
333
335
|
*/
|
|
334
336
|
export declare function evaluateStage(rules: EvaluableRule[], fields: Record<string, string | null>, mappedFields: Set<string>): string | null;
|
|
335
337
|
//#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 };
|
|
338
|
+
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.
|
|
@@ -17,7 +17,7 @@ import { EntitySyncResult, MirrorRecord, PersistAction, SourceRow, SyncAction, S
|
|
|
17
17
|
* D8 made for stage-rule conditions.
|
|
18
18
|
*/
|
|
19
19
|
interface PerSeatValueSource {
|
|
20
|
-
/** Flat
|
|
20
|
+
/** Flat fee in cents, using the same price basis as the plan prices. */
|
|
21
21
|
baseFeeCents?: number;
|
|
22
22
|
/** Mapped column holding "month" or "year". Absent means always monthly. */
|
|
23
23
|
intervalField?: string;
|
|
@@ -26,6 +26,8 @@ interface PerSeatValueSource {
|
|
|
26
26
|
planField: string;
|
|
27
27
|
/** Unit price in cents, keyed by the value found in `planField`. */
|
|
28
28
|
plans: Record<string, number>;
|
|
29
|
+
/** Monthly rates or amounts charged each billing period. Omitted preserves legacy behaviour. */
|
|
30
|
+
priceBasis?: "monthly" | "billing_interval";
|
|
29
31
|
/** Mapped column holding the seat count. */
|
|
30
32
|
seatField: string;
|
|
31
33
|
/** Stage types whose deals are worth nothing (usually `["lost"]`). */
|
|
@@ -333,4 +335,4 @@ export declare function matchesCondition(condition: Condition, fields: Record<st
|
|
|
333
335
|
*/
|
|
334
336
|
export declare function evaluateStage(rules: EvaluableRule[], fields: Record<string, string | null>, mappedFields: Set<string>): string | null;
|
|
335
337
|
//#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 };
|
|
338
|
+
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;
|
|
@@ -32,6 +32,18 @@ function computeDealValue(source, fields, mappedFields, stageType) {
|
|
|
32
32
|
const seats = Number(rawSeats);
|
|
33
33
|
if (!Number.isSafeInteger(seats)) return null;
|
|
34
34
|
const monthly = (source.baseFeeCents ?? 0) + seats * unitPriceCents;
|
|
35
|
+
if (!Number.isSafeInteger(monthly) || monthly < 0) return null;
|
|
36
|
+
return normalizePrice(monthly, source, fields, mappedFields);
|
|
37
|
+
}
|
|
38
|
+
/** Preserve old configurations while making new price bases explicit. */
|
|
39
|
+
function normalizePrice(monthly, source, fields, mappedFields) {
|
|
40
|
+
if (source.priceBasis === "monthly") return monthly;
|
|
41
|
+
if (source.priceBasis === "billing_interval") {
|
|
42
|
+
const interval = source.intervalField && mappedFields.has(source.intervalField) ? fields[source.intervalField]?.trim().toLowerCase() : null;
|
|
43
|
+
if (interval === "month" || interval === "monthly") return monthly;
|
|
44
|
+
if (interval === "year" || interval === "annual" || interval === "annually" || interval === "yearly") return Math.round(monthly / MONTHS_PER_YEAR);
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
35
47
|
return source.intervalField !== void 0 && mappedFields.has(source.intervalField) && fields[source.intervalField] === "year" ? Math.round(monthly / MONTHS_PER_YEAR) : monthly;
|
|
36
48
|
}
|
|
37
49
|
//#endregion
|
|
@@ -256,4 +268,4 @@ function evaluateStage(rules, fields, mappedFields) {
|
|
|
256
268
|
return null;
|
|
257
269
|
}
|
|
258
270
|
//#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 };
|
|
271
|
+
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 };
|
|
@@ -346,6 +346,7 @@ exports.DEFAULT_HEARTBEAT_INTERVAL_MS = DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
|
346
346
|
exports.INSTALL_COMMAND = INSTALL_COMMAND;
|
|
347
347
|
exports.INTEGRATION_STEPS = INTEGRATION_STEPS;
|
|
348
348
|
exports.INTEGRATION_STEP_LABELS = INTEGRATION_STEP_LABELS;
|
|
349
|
+
exports.LIVE_INTERVALS = LIVE_INTERVALS;
|
|
349
350
|
exports.STEP_PREREQUISITES = STEP_PREREQUISITES;
|
|
350
351
|
exports.computeIntegrationState = computeIntegrationState;
|
|
351
352
|
exports.deriveLiveness = deriveLiveness;
|
|
@@ -111,6 +111,7 @@ export interface IntegrationStep {
|
|
|
111
111
|
export declare function formatAge(ms: number): string;
|
|
112
112
|
/** The cadence `createClivlySDK` uses when the host app configures none. */
|
|
113
113
|
export declare const DEFAULT_HEARTBEAT_INTERVAL_MS = 60000;
|
|
114
|
+
export declare const LIVE_INTERVALS = 2.5;
|
|
114
115
|
export type ConnectionHealth = "live" | "live_with_issues" | "stale" | "offline";
|
|
115
116
|
/**
|
|
116
117
|
* Clivly's own answer to "is this app up", obtained by asking it rather than
|
|
@@ -111,6 +111,7 @@ export interface IntegrationStep {
|
|
|
111
111
|
export declare function formatAge(ms: number): string;
|
|
112
112
|
/** The cadence `createClivlySDK` uses when the host app configures none. */
|
|
113
113
|
export declare const DEFAULT_HEARTBEAT_INTERVAL_MS = 60000;
|
|
114
|
+
export declare const LIVE_INTERVALS = 2.5;
|
|
114
115
|
export type ConnectionHealth = "live" | "live_with_issues" | "stale" | "offline";
|
|
115
116
|
/**
|
|
116
117
|
* Clivly's own answer to "is this app up", obtained by asking it rather than
|
|
@@ -341,4 +341,4 @@ function computeIntegrationState(facts) {
|
|
|
341
341
|
};
|
|
342
342
|
}
|
|
343
343
|
//#endregion
|
|
344
|
-
export { DEFAULT_HEARTBEAT_INTERVAL_MS, INSTALL_COMMAND, INTEGRATION_STEPS, INTEGRATION_STEP_LABELS, STEP_PREREQUISITES, computeIntegrationState, deriveLiveness, deriveStatus, formatAge, livenessWindows, nextAction, resolveSteps, stepIndex };
|
|
344
|
+
export { DEFAULT_HEARTBEAT_INTERVAL_MS, INSTALL_COMMAND, INTEGRATION_STEPS, INTEGRATION_STEP_LABELS, LIVE_INTERVALS, STEP_PREREQUISITES, computeIntegrationState, deriveLiveness, deriveStatus, formatAge, livenessWindows, nextAction, resolveSteps, stepIndex };
|
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 };
|
package/dist/types.d.cts
CHANGED
|
@@ -84,12 +84,21 @@ export interface Company {
|
|
|
84
84
|
}
|
|
85
85
|
export interface Deal {
|
|
86
86
|
closeDate: Date | null;
|
|
87
|
+
/** The organisation this deal belongs to, when one is linked. */
|
|
88
|
+
companyId: string | null;
|
|
87
89
|
contactId: string | null;
|
|
88
90
|
createdAt: Date;
|
|
89
91
|
id: string;
|
|
90
92
|
name: string;
|
|
91
93
|
orgId: string;
|
|
92
94
|
owner: string | null;
|
|
95
|
+
/**
|
|
96
|
+
* The board this deal belongs to. NULL means the org's default pipeline: the
|
|
97
|
+
* sync mirror wrote deals before pipelines existed, and a deal created with
|
|
98
|
+
* no pipeline lands on the default. Stage keys are unique per pipeline, so
|
|
99
|
+
* a board must filter on this, never on stage key alone.
|
|
100
|
+
*/
|
|
101
|
+
pipelineId: string | null;
|
|
93
102
|
position: number | null;
|
|
94
103
|
stage: DealStage;
|
|
95
104
|
/**
|
|
@@ -116,6 +125,11 @@ export interface Task {
|
|
|
116
125
|
createdAt: Date;
|
|
117
126
|
createdBy: string | null;
|
|
118
127
|
description: string | null;
|
|
128
|
+
/**
|
|
129
|
+
* Whether `dueAt` is a calendar date (stored as midnight UTC) rather than a
|
|
130
|
+
* moment. Absent or null: a midnight-UTC `dueAt` is read as a date.
|
|
131
|
+
*/
|
|
132
|
+
dueAllDay?: boolean | null;
|
|
119
133
|
dueAt: Date | null;
|
|
120
134
|
entityId: string | null;
|
|
121
135
|
entityType: TaskEntityType | null;
|
|
@@ -203,6 +217,11 @@ export interface CreateTaskInput {
|
|
|
203
217
|
assignee?: string;
|
|
204
218
|
createdBy?: string;
|
|
205
219
|
description?: string;
|
|
220
|
+
/**
|
|
221
|
+
* Whether `dueAt` is a calendar date (stored as midnight UTC) rather than a
|
|
222
|
+
* moment. Absent or null: a midnight-UTC `dueAt` is read as a date.
|
|
223
|
+
*/
|
|
224
|
+
dueAllDay?: boolean | null;
|
|
206
225
|
dueAt?: Date;
|
|
207
226
|
entityId?: string;
|
|
208
227
|
entityType?: TaskEntityType;
|
|
@@ -212,6 +231,11 @@ export interface CreateTaskInput {
|
|
|
212
231
|
export interface UpdateTaskInput {
|
|
213
232
|
assignee?: string | null;
|
|
214
233
|
description?: string | null;
|
|
234
|
+
/**
|
|
235
|
+
* Whether `dueAt` is a calendar date (stored as midnight UTC) rather than a
|
|
236
|
+
* moment. Absent or null: a midnight-UTC `dueAt` is read as a date.
|
|
237
|
+
*/
|
|
238
|
+
dueAllDay?: boolean | null;
|
|
215
239
|
dueAt?: Date | null;
|
|
216
240
|
entityId?: string | null;
|
|
217
241
|
entityType?: TaskEntityType | null;
|
|
@@ -244,6 +268,17 @@ export interface ContactFilters {
|
|
|
244
268
|
* row is no longer sync-provenanced.
|
|
245
269
|
*/
|
|
246
270
|
excludeArchived?: boolean;
|
|
271
|
+
/**
|
|
272
|
+
* Case-insensitive substring match on name or email.
|
|
273
|
+
*
|
|
274
|
+
* Applied in SQL, not over an already-fetched page: a filter applied after
|
|
275
|
+
* paging returns a partial page and a cursor that skips the rows it dropped,
|
|
276
|
+
* so "page 2" of a search is not the second page of the matches. An empty or
|
|
277
|
+
* whitespace-only value is ignored rather than matching nothing.
|
|
278
|
+
*
|
|
279
|
+
* LIKE metacharacters in the term are escaped and matched literally — a
|
|
280
|
+
* search for `%` finds contacts containing a percent sign, not every row.
|
|
281
|
+
*/
|
|
247
282
|
search?: string;
|
|
248
283
|
source?: RecordSource;
|
|
249
284
|
status?: ContactStatus;
|
package/dist/types.d.mts
CHANGED
|
@@ -84,12 +84,21 @@ export interface Company {
|
|
|
84
84
|
}
|
|
85
85
|
export interface Deal {
|
|
86
86
|
closeDate: Date | null;
|
|
87
|
+
/** The organisation this deal belongs to, when one is linked. */
|
|
88
|
+
companyId: string | null;
|
|
87
89
|
contactId: string | null;
|
|
88
90
|
createdAt: Date;
|
|
89
91
|
id: string;
|
|
90
92
|
name: string;
|
|
91
93
|
orgId: string;
|
|
92
94
|
owner: string | null;
|
|
95
|
+
/**
|
|
96
|
+
* The board this deal belongs to. NULL means the org's default pipeline: the
|
|
97
|
+
* sync mirror wrote deals before pipelines existed, and a deal created with
|
|
98
|
+
* no pipeline lands on the default. Stage keys are unique per pipeline, so
|
|
99
|
+
* a board must filter on this, never on stage key alone.
|
|
100
|
+
*/
|
|
101
|
+
pipelineId: string | null;
|
|
93
102
|
position: number | null;
|
|
94
103
|
stage: DealStage;
|
|
95
104
|
/**
|
|
@@ -116,6 +125,11 @@ export interface Task {
|
|
|
116
125
|
createdAt: Date;
|
|
117
126
|
createdBy: string | null;
|
|
118
127
|
description: string | null;
|
|
128
|
+
/**
|
|
129
|
+
* Whether `dueAt` is a calendar date (stored as midnight UTC) rather than a
|
|
130
|
+
* moment. Absent or null: a midnight-UTC `dueAt` is read as a date.
|
|
131
|
+
*/
|
|
132
|
+
dueAllDay?: boolean | null;
|
|
119
133
|
dueAt: Date | null;
|
|
120
134
|
entityId: string | null;
|
|
121
135
|
entityType: TaskEntityType | null;
|
|
@@ -203,6 +217,11 @@ export interface CreateTaskInput {
|
|
|
203
217
|
assignee?: string;
|
|
204
218
|
createdBy?: string;
|
|
205
219
|
description?: string;
|
|
220
|
+
/**
|
|
221
|
+
* Whether `dueAt` is a calendar date (stored as midnight UTC) rather than a
|
|
222
|
+
* moment. Absent or null: a midnight-UTC `dueAt` is read as a date.
|
|
223
|
+
*/
|
|
224
|
+
dueAllDay?: boolean | null;
|
|
206
225
|
dueAt?: Date;
|
|
207
226
|
entityId?: string;
|
|
208
227
|
entityType?: TaskEntityType;
|
|
@@ -212,6 +231,11 @@ export interface CreateTaskInput {
|
|
|
212
231
|
export interface UpdateTaskInput {
|
|
213
232
|
assignee?: string | null;
|
|
214
233
|
description?: string | null;
|
|
234
|
+
/**
|
|
235
|
+
* Whether `dueAt` is a calendar date (stored as midnight UTC) rather than a
|
|
236
|
+
* moment. Absent or null: a midnight-UTC `dueAt` is read as a date.
|
|
237
|
+
*/
|
|
238
|
+
dueAllDay?: boolean | null;
|
|
215
239
|
dueAt?: Date | null;
|
|
216
240
|
entityId?: string | null;
|
|
217
241
|
entityType?: TaskEntityType | null;
|
|
@@ -244,6 +268,17 @@ export interface ContactFilters {
|
|
|
244
268
|
* row is no longer sync-provenanced.
|
|
245
269
|
*/
|
|
246
270
|
excludeArchived?: boolean;
|
|
271
|
+
/**
|
|
272
|
+
* Case-insensitive substring match on name or email.
|
|
273
|
+
*
|
|
274
|
+
* Applied in SQL, not over an already-fetched page: a filter applied after
|
|
275
|
+
* paging returns a partial page and a cursor that skips the rows it dropped,
|
|
276
|
+
* so "page 2" of a search is not the second page of the matches. An empty or
|
|
277
|
+
* whitespace-only value is ignored rather than matching nothing.
|
|
278
|
+
*
|
|
279
|
+
* LIKE metacharacters in the term are escaped and matched literally — a
|
|
280
|
+
* search for `%` finds contacts containing a percent sign, not every row.
|
|
281
|
+
*/
|
|
247
282
|
search?: string;
|
|
248
283
|
source?: RecordSource;
|
|
249
284
|
status?: ContactStatus;
|