@porulle/plugin-channel-connector 0.26.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 +2 -1
- package/dist/service.js +59 -20
- package/package.json +2 -2
- package/src/schema.ts +2 -1
- package/src/service.ts +72 -20
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
|
-
|
|
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
|
-
|
|
1876
|
-
|
|
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
|
-
|
|
1882
|
-
const
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
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.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"@hono/zod-openapi": "^1.2.2",
|
|
21
21
|
"hono": "^4.12.5",
|
|
22
|
-
"@porulle/core": "0.
|
|
22
|
+
"@porulle/core": "0.28.0"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"@types/node": "^24.5.2",
|
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
|
-
|
|
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
|
-
|
|
2558
|
-
|
|
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,
|