@porulle/plugin-channel-connector 0.27.0 → 0.28.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/dist/schema.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { boolean, index, integer, jsonb, pgTable, text, timestamp, uniqueIndex, uuid, sql, } from "@porulle/core/drizzle";
2
+ import { sellableEntities } from "@porulle/core/schema";
2
3
  export const connectedStores = pgTable("connected_stores", {
3
4
  id: uuid("id").defaultRandom().primaryKey(),
4
5
  organizationId: text("organization_id").notNull(),
@@ -29,7 +30,7 @@ export const channelEntityMap = pgTable("channel_entity_map", {
29
30
  storeId: uuid("store_id").references(() => connectedStores.id, { onDelete: "cascade" }).notNull(),
30
31
  kind: text("kind", { enum: ["entity", "variant"] }).notNull(),
31
32
  externalId: text("external_id").notNull(),
32
- entityId: uuid("entity_id").notNull(),
33
+ entityId: uuid("entity_id").references(() => sellableEntities.id, { onDelete: "cascade" }).notNull(),
33
34
  variantId: uuid("variant_id"),
34
35
  lastSyncedAt: timestamp("last_synced_at", { withTimezone: true }).defaultNow().notNull(),
35
36
  syncHash: text("sync_hash").notNull(),
package/dist/service.js CHANGED
@@ -72,6 +72,8 @@ export function canCatalogPushTransition(from, to) {
72
72
  function hash(value) {
73
73
  return createHash("sha256").update(JSON.stringify(value)).digest("hex");
74
74
  }
75
+ /** Mid-import map rows carry this until convergence finishes; must not equal any real remote hash. */
76
+ const PENDING_ENTITY_MAP_SYNC_HASH = "";
75
77
  export const CATALOG_OUTBOUND_SUPPRESSION_WINDOW_MS = 15 * 60 * 1000;
76
78
  function normalizeCanonicalValue(value) {
77
79
  if (typeof value === "string")
@@ -1864,35 +1866,64 @@ export class ChannelConnectorService {
1864
1866
  .select()
1865
1867
  .from(channelEntityMap)
1866
1868
  .where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, item.externalId)));
1867
- const entityMapping = existing.find((entry) => entry.kind === "entity");
1869
+ let entityMapping = existing.find((entry) => entry.kind === "entity");
1868
1870
  let entityId;
1869
1871
  let isNew = false;
1872
+ let adoptedOrphan = false;
1870
1873
  let entityTouched = false;
1871
1874
  let existingEntity;
1872
1875
  if (entityMapping) {
1873
1876
  const [entity] = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, entityMapping.entityId)));
1874
1877
  if (!entity) {
1875
- warnings.push(`Skipped "${item.externalId}": mapped entity ${entityMapping.entityId} no longer exists.`);
1876
- continue;
1878
+ await this.db.delete(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.entityId, entityMapping.entityId)));
1879
+ entityMapping = undefined;
1880
+ }
1881
+ else {
1882
+ entityId = entityMapping.entityId;
1883
+ existingEntity = entity;
1877
1884
  }
1878
- entityId = entityMapping.entityId;
1879
- existingEntity = entity;
1880
1885
  }
1881
- else {
1882
- const status = item.status;
1883
- const entity = await this.catalog.create({
1884
- type: "product",
1885
- slug: item.slug,
1886
- sourceStoreId: storeId,
1887
- metadata: mergeMetadata(undefined, item.metadata ?? {}),
1888
- ...(status !== undefined ? { status, isVisible: status === "active" } : {}),
1889
- }, actor);
1890
- if (!entity.ok)
1891
- return PluginErr(entity.error.message);
1892
- entityId = entity.value.id;
1893
- isNew = true;
1894
- imported += 1;
1895
- entityTouched = true;
1886
+ if (entityId === undefined) {
1887
+ const [orphan] = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.sourceStoreId, storeId), eq(sellableEntities.slug, item.slug)));
1888
+ if (orphan) {
1889
+ entityId = orphan.id;
1890
+ existingEntity = orphan;
1891
+ adoptedOrphan = true;
1892
+ }
1893
+ else {
1894
+ const status = item.status;
1895
+ try {
1896
+ entityId = await this.transact(async (tx) => {
1897
+ const txContext = createTxContext(tx, { actor });
1898
+ const created = await this.catalog.create({
1899
+ type: "product",
1900
+ slug: item.slug,
1901
+ sourceStoreId: storeId,
1902
+ metadata: mergeMetadata(undefined, item.metadata ?? {}),
1903
+ ...(status !== undefined ? { status, isVisible: status === "active" } : {}),
1904
+ }, actor, txContext);
1905
+ if (!created.ok)
1906
+ throw new Error(created.error.message);
1907
+ await tx.insert(channelEntityMap).values({
1908
+ organizationId: orgId,
1909
+ storeId,
1910
+ kind: "entity",
1911
+ externalId: item.externalId,
1912
+ entityId: created.value.id,
1913
+ syncHash: PENDING_ENTITY_MAP_SYNC_HASH,
1914
+ heldFieldPaths: [],
1915
+ forcedPushFieldPaths: [],
1916
+ });
1917
+ return created.value.id;
1918
+ });
1919
+ }
1920
+ catch (error) {
1921
+ return PluginErr(error instanceof Error ? error.message : "Failed to create catalog entity.");
1922
+ }
1923
+ isNew = true;
1924
+ imported += 1;
1925
+ entityTouched = true;
1926
+ }
1896
1927
  }
1897
1928
  const ownershipBeforeSeed = await this.catalog.resolveFieldOwners(entityId, storeId);
1898
1929
  const seedPaths = importedFieldPaths(item).filter((path) => !ownershipBeforeSeed.has(path));
@@ -1989,6 +2020,14 @@ export class ChannelConnectorService {
1989
2020
  const latestRevisionAt = revisionMarkers.at(-1)?.createdAt;
1990
2021
  const lastSyncedAt = latestRevisionAt ?? entityMapping?.lastSyncedAt ?? new Date();
1991
2022
  if (isNew) {
2023
+ await this.db.update(channelEntityMap).set({
2024
+ syncHash: remoteHash,
2025
+ lastSyncedAt,
2026
+ heldFieldPaths: heldSharedPaths,
2027
+ forcedPushFieldPaths: survivingForcedPaths,
2028
+ }).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, item.externalId), eq(channelEntityMap.entityId, entityId)));
2029
+ }
2030
+ else if (adoptedOrphan) {
1992
2031
  await this.db.insert(channelEntityMap).values({
1993
2032
  organizationId: orgId,
1994
2033
  storeId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/plugin-channel-connector",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -19,15 +19,15 @@
19
19
  "dependencies": {
20
20
  "@hono/zod-openapi": "^1.2.2",
21
21
  "hono": "^4.12.5",
22
- "@porulle/core": "0.27.0"
22
+ "@porulle/core": "0.28.0"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/node": "^24.5.2",
26
26
  "eslint": "^9.39.1",
27
27
  "typescript": "5.9.2",
28
28
  "vitest": "^3.2.4",
29
- "@porulle/eslint-config": "0.1.0",
30
- "@porulle/typescript-config": "0.1.0"
29
+ "@porulle/typescript-config": "0.1.0",
30
+ "@porulle/eslint-config": "0.1.0"
31
31
  },
32
32
  "publishConfig": {
33
33
  "access": "public"
package/src/schema.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  sql,
12
12
  } from "@porulle/core/drizzle";
13
13
  import type { ChannelPushCatalogItem, FieldPath } from "@porulle/core";
14
+ import { sellableEntities } from "@porulle/core/schema";
14
15
  import type { CatalogFieldMapping } from "./catalog-field-mapping.js";
15
16
 
16
17
  export const connectedStores = pgTable(
@@ -50,7 +51,7 @@ export const channelEntityMap = pgTable(
50
51
  storeId: uuid("store_id").references(() => connectedStores.id, { onDelete: "cascade" }).notNull(),
51
52
  kind: text("kind", { enum: ["entity", "variant"] }).notNull(),
52
53
  externalId: text("external_id").notNull(),
53
- entityId: uuid("entity_id").notNull(),
54
+ entityId: uuid("entity_id").references(() => sellableEntities.id, { onDelete: "cascade" }).notNull(),
54
55
  variantId: uuid("variant_id"),
55
56
  lastSyncedAt: timestamp("last_synced_at", { withTimezone: true }).defaultNow().notNull(),
56
57
  syncHash: text("sync_hash").notNull(),
package/src/service.ts CHANGED
@@ -343,6 +343,7 @@ interface CatalogService {
343
343
  isVisible?: boolean;
344
344
  },
345
345
  actor: Actor,
346
+ ctx?: TxContext,
346
347
  ): Promise<{ ok: true; value: { id: string } } | { ok: false; error: { message: string } }>;
347
348
  createVariant(
348
349
  input: { entityId: string; options: Record<string, string>; sku?: string; barcode?: string },
@@ -481,6 +482,9 @@ function hash(value: unknown): string {
481
482
  return createHash("sha256").update(JSON.stringify(value)).digest("hex");
482
483
  }
483
484
 
485
+ /** Mid-import map rows carry this until convergence finishes; must not equal any real remote hash. */
486
+ const PENDING_ENTITY_MAP_SYNC_HASH = "";
487
+
484
488
  export const CATALOG_OUTBOUND_SUPPRESSION_WINDOW_MS = 15 * 60 * 1000;
485
489
 
486
490
  function normalizeCanonicalValue(value: unknown): unknown {
@@ -2543,9 +2547,10 @@ export class ChannelConnectorService {
2543
2547
  eq(channelEntityMap.kind, "entity"),
2544
2548
  eq(channelEntityMap.externalId, item.externalId),
2545
2549
  ));
2546
- const entityMapping = existing.find((entry) => entry.kind === "entity");
2547
- let entityId: string;
2550
+ let entityMapping = existing.find((entry) => entry.kind === "entity");
2551
+ let entityId: string | undefined;
2548
2552
  let isNew = false;
2553
+ let adoptedOrphan = false;
2549
2554
  let entityTouched = false;
2550
2555
  let existingEntity: typeof sellableEntities.$inferSelect | undefined;
2551
2556
  if (entityMapping) {
@@ -2554,25 +2559,59 @@ export class ChannelConnectorService {
2554
2559
  eq(sellableEntities.id, entityMapping.entityId),
2555
2560
  ));
2556
2561
  if (!entity) {
2557
- warnings.push(`Skipped "${item.externalId}": mapped entity ${entityMapping.entityId} no longer exists.`);
2558
- continue;
2562
+ await this.db.delete(channelEntityMap).where(and(
2563
+ eq(channelEntityMap.organizationId, orgId),
2564
+ eq(channelEntityMap.storeId, storeId),
2565
+ eq(channelEntityMap.entityId, entityMapping.entityId),
2566
+ ));
2567
+ entityMapping = undefined;
2568
+ } else {
2569
+ entityId = entityMapping.entityId;
2570
+ existingEntity = entity;
2571
+ }
2572
+ }
2573
+ if (entityId === undefined) {
2574
+ const [orphan] = await this.db.select().from(sellableEntities).where(and(
2575
+ eq(sellableEntities.organizationId, orgId),
2576
+ eq(sellableEntities.sourceStoreId, storeId),
2577
+ eq(sellableEntities.slug, item.slug),
2578
+ ));
2579
+ if (orphan) {
2580
+ entityId = orphan.id;
2581
+ existingEntity = orphan;
2582
+ adoptedOrphan = true;
2583
+ } else {
2584
+ const status = item.status;
2585
+ try {
2586
+ entityId = await this.transact(async (tx) => {
2587
+ const txContext = createTxContext(tx, { actor });
2588
+ const created = await this.catalog.create({
2589
+ type: "product",
2590
+ slug: item.slug,
2591
+ sourceStoreId: storeId,
2592
+ metadata: mergeMetadata(undefined, item.metadata ?? {}),
2593
+ ...(status !== undefined ? { status, isVisible: status === "active" } : {}),
2594
+ }, actor, txContext);
2595
+ if (!created.ok) throw new Error(created.error.message);
2596
+ await tx.insert(channelEntityMap).values({
2597
+ organizationId: orgId,
2598
+ storeId,
2599
+ kind: "entity",
2600
+ externalId: item.externalId,
2601
+ entityId: created.value.id,
2602
+ syncHash: PENDING_ENTITY_MAP_SYNC_HASH,
2603
+ heldFieldPaths: [],
2604
+ forcedPushFieldPaths: [],
2605
+ });
2606
+ return created.value.id;
2607
+ });
2608
+ } catch (error) {
2609
+ return PluginErr(error instanceof Error ? error.message : "Failed to create catalog entity.");
2610
+ }
2611
+ isNew = true;
2612
+ imported += 1;
2613
+ entityTouched = true;
2559
2614
  }
2560
- entityId = entityMapping.entityId;
2561
- existingEntity = entity;
2562
- } else {
2563
- const status = item.status;
2564
- const entity = await this.catalog.create({
2565
- type: "product",
2566
- slug: item.slug,
2567
- sourceStoreId: storeId,
2568
- metadata: mergeMetadata(undefined, item.metadata ?? {}),
2569
- ...(status !== undefined ? { status, isVisible: status === "active" } : {}),
2570
- }, actor);
2571
- if (!entity.ok) return PluginErr(entity.error.message);
2572
- entityId = entity.value.id;
2573
- isNew = true;
2574
- imported += 1;
2575
- entityTouched = true;
2576
2615
  }
2577
2616
 
2578
2617
  const ownershipBeforeSeed = await this.catalog.resolveFieldOwners(entityId, storeId);
@@ -2684,6 +2723,19 @@ export class ChannelConnectorService {
2684
2723
  const lastSyncedAt = latestRevisionAt ?? entityMapping?.lastSyncedAt ?? new Date();
2685
2724
 
2686
2725
  if (isNew) {
2726
+ await this.db.update(channelEntityMap).set({
2727
+ syncHash: remoteHash,
2728
+ lastSyncedAt,
2729
+ heldFieldPaths: heldSharedPaths,
2730
+ forcedPushFieldPaths: survivingForcedPaths,
2731
+ }).where(and(
2732
+ eq(channelEntityMap.organizationId, orgId),
2733
+ eq(channelEntityMap.storeId, storeId),
2734
+ eq(channelEntityMap.kind, "entity"),
2735
+ eq(channelEntityMap.externalId, item.externalId),
2736
+ eq(channelEntityMap.entityId, entityId),
2737
+ ));
2738
+ } else if (adoptedOrphan) {
2687
2739
  await this.db.insert(channelEntityMap).values({
2688
2740
  organizationId: orgId,
2689
2741
  storeId,