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