@porulle/plugin-channel-connector 0.50.0 → 0.50.2
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/index.js +5 -1
- package/dist/service.d.ts +9 -0
- package/dist/service.js +71 -21
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/index.ts +5 -1
- package/src/service.ts +90 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/plugin-channel-connector",
|
|
3
|
-
"version": "0.50.
|
|
3
|
+
"version": "0.50.2",
|
|
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.50.
|
|
22
|
+
"@porulle/core": "0.50.2"
|
|
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/
|
|
30
|
-
"@porulle/
|
|
29
|
+
"@porulle/eslint-config": "0.1.0",
|
|
30
|
+
"@porulle/typescript-config": "0.1.0"
|
|
31
31
|
},
|
|
32
32
|
"publishConfig": {
|
|
33
33
|
"access": "public"
|
package/src/index.ts
CHANGED
|
@@ -308,7 +308,11 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
308
308
|
handler: async ({ input, ctx }: { input: Record<string, unknown>; ctx: import("@porulle/core").TaskContext }) => {
|
|
309
309
|
const orgId = String(input.orgId);
|
|
310
310
|
const jobs = ctx.services.jobs as JobsAdapter;
|
|
311
|
-
|
|
311
|
+
// Only stores a registered connector can reach: a reconcile for any other provider (the
|
|
312
|
+
// seed's `manual` point-of-sale store) can only fail with "No connector registered".
|
|
313
|
+
const providers = new Set((options.connectors ?? []).map((connector) => connector.providerId));
|
|
314
|
+
const stores = (await ctx.db.select().from(connectedStores).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.status, "connected"))))
|
|
315
|
+
.filter((store) => providers.has(store.provider));
|
|
312
316
|
const window = options.reconcileJitterWindowMs ?? 60 * 60 * 1000;
|
|
313
317
|
for (const store of stores) {
|
|
314
318
|
const offset = createHash("sha256").update(store.id).digest().readUInt32BE(0) % window;
|
package/src/service.ts
CHANGED
|
@@ -40,6 +40,8 @@ import {
|
|
|
40
40
|
customerAddresses,
|
|
41
41
|
customers,
|
|
42
42
|
entityMedia,
|
|
43
|
+
entityBrands,
|
|
44
|
+
entityCategories,
|
|
43
45
|
entityTags,
|
|
44
46
|
inventoryLevels,
|
|
45
47
|
mediaAssets,
|
|
@@ -165,6 +167,9 @@ export interface ReconcileReport extends Record<string, unknown> {
|
|
|
165
167
|
skipped?: CatalogFieldSkip[];
|
|
166
168
|
conflicts?: CatalogFieldConflict[];
|
|
167
169
|
warnings?: string[];
|
|
170
|
+
/** Items this reconcile could not converge, each with its error. A reconcile that dropped an
|
|
171
|
+
* item is never reported as a clean one. */
|
|
172
|
+
failures?: CatalogConvergenceFailure[];
|
|
168
173
|
}
|
|
169
174
|
|
|
170
175
|
export interface CatalogFieldConflict {
|
|
@@ -1633,6 +1638,22 @@ export class ChannelConnectorService {
|
|
|
1633
1638
|
existingOptionValues.set(row.variantId, list);
|
|
1634
1639
|
}
|
|
1635
1640
|
}
|
|
1641
|
+
// The product's plain base prices, read once. `setBasePrice` upserts and fires its hook even at
|
|
1642
|
+
// the same amount, so an unchanged price is skipped here — and a price that IS written is what
|
|
1643
|
+
// makes the variant "changed", which is what `converged` counts.
|
|
1644
|
+
const storedPrices = item.variants.some((variant) => (variant.prices ?? []).length > 0)
|
|
1645
|
+
? await this.db.select({ variantId: prices.variantId, currency: prices.currency, amount: prices.amount, compareAtAmount: prices.compareAtAmount })
|
|
1646
|
+
.from(prices)
|
|
1647
|
+
.where(and(
|
|
1648
|
+
eq(prices.organizationId, orgId),
|
|
1649
|
+
eq(prices.entityId, entityId),
|
|
1650
|
+
isNull(prices.customerGroupId),
|
|
1651
|
+
isNull(prices.minQuantity),
|
|
1652
|
+
isNull(prices.maxQuantity),
|
|
1653
|
+
isNull(prices.validFrom),
|
|
1654
|
+
isNull(prices.validUntil),
|
|
1655
|
+
))
|
|
1656
|
+
: [];
|
|
1636
1657
|
for (const sourceVariant of item.variants) {
|
|
1637
1658
|
const fullSourceVariant = fullItem.variants.find((variant) => variant.externalId === sourceVariant.externalId) ?? sourceVariant;
|
|
1638
1659
|
let mapping = mappings.find((row) => row.externalId === sourceVariant.externalId);
|
|
@@ -1738,6 +1759,9 @@ export class ChannelConnectorService {
|
|
|
1738
1759
|
}
|
|
1739
1760
|
}
|
|
1740
1761
|
for (const price of sourceVariant.prices ?? []) {
|
|
1762
|
+
const currency = price.currency.trim().toUpperCase();
|
|
1763
|
+
const stored = storedPrices.find((row) => row.variantId === variantId && row.currency === currency);
|
|
1764
|
+
if (stored && stored.amount === price.amount && stored.compareAtAmount === (price.compareAtAmount ?? null)) continue;
|
|
1741
1765
|
const priced = await this.pricing.setBasePrice({
|
|
1742
1766
|
entityId,
|
|
1743
1767
|
variantId,
|
|
@@ -1746,6 +1770,7 @@ export class ChannelConnectorService {
|
|
|
1746
1770
|
compareAtAmount: price.compareAtAmount ?? null,
|
|
1747
1771
|
}, actor);
|
|
1748
1772
|
if (!priced.ok) return PluginErr(priced.error.message);
|
|
1773
|
+
changed = true;
|
|
1749
1774
|
}
|
|
1750
1775
|
// Was UNCONDITIONAL: one UPDATE per variant on every import, including a re-sync where the
|
|
1751
1776
|
// variant is byte-identical. `mapping.syncHash` is already in hand from the select above, so
|
|
@@ -1806,14 +1831,48 @@ export class ChannelConnectorService {
|
|
|
1806
1831
|
return this.taxonomyCache;
|
|
1807
1832
|
}
|
|
1808
1833
|
|
|
1834
|
+
/**
|
|
1835
|
+
* Create a category or brand, or adopt the one another writer created first. The taxonomy
|
|
1836
|
+
* snapshot is per converge, so two stores converging at once can both see a slug missing and both
|
|
1837
|
+
* create it; the loser's conflict — returned or thrown — means the row it wanted now exists.
|
|
1838
|
+
*/
|
|
1839
|
+
private async createTaxonomyOrAdopt<Row>(
|
|
1840
|
+
create: () => Promise<{ ok: true; value: { id: string } } | { ok: false; error: { message: string } }>,
|
|
1841
|
+
byId: (id: string) => Promise<Row | undefined>,
|
|
1842
|
+
bySlug: () => Promise<Row | undefined>,
|
|
1843
|
+
label: string,
|
|
1844
|
+
): Promise<PluginResult<Row>> {
|
|
1845
|
+
let failure: unknown = `${label} was not persisted.`;
|
|
1846
|
+
try {
|
|
1847
|
+
const created = await create();
|
|
1848
|
+
if (created.ok) {
|
|
1849
|
+
const row = await byId(created.value.id);
|
|
1850
|
+
if (row !== undefined) return Ok(row);
|
|
1851
|
+
} else {
|
|
1852
|
+
failure = created.error;
|
|
1853
|
+
}
|
|
1854
|
+
} catch (error) {
|
|
1855
|
+
failure = error;
|
|
1856
|
+
}
|
|
1857
|
+
const existing = await bySlug();
|
|
1858
|
+
return existing !== undefined ? Ok(existing) : PluginErr(`${label}: ${errorMessage(failure)}`);
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1809
1861
|
private async applyTaxonomy(
|
|
1810
1862
|
orgId: string,
|
|
1811
1863
|
entityId: string,
|
|
1812
1864
|
item: ChannelCatalogItem,
|
|
1813
1865
|
actor: Actor,
|
|
1814
1866
|
warnings: string[],
|
|
1815
|
-
): Promise<PluginResult<
|
|
1867
|
+
): Promise<PluginResult<{ changed: boolean }>> {
|
|
1816
1868
|
const taxonomy = await this.taxonomyFor(orgId);
|
|
1869
|
+
// The links this entity already has, read only for the classes the item names. A link already
|
|
1870
|
+
// there is not re-written, and a link that is added is what reports the taxonomy as changed.
|
|
1871
|
+
const linkedCategories = new Set((item.categories ?? []).length === 0 ? [] : (await this.db
|
|
1872
|
+
.select({ id: entityCategories.categoryId }).from(entityCategories).where(eq(entityCategories.entityId, entityId))).map((row) => row.id));
|
|
1873
|
+
const linkedBrands = new Set(!item.brand ? [] : (await this.db
|
|
1874
|
+
.select({ id: entityBrands.brandId }).from(entityBrands).where(eq(entityBrands.entityId, entityId))).map((row) => row.id));
|
|
1875
|
+
let changed = false;
|
|
1817
1876
|
const categoryRows = taxonomy.categories;
|
|
1818
1877
|
for (const slug of new Set(item.categories ?? [])) {
|
|
1819
1878
|
let category = categoryRows.find((row) => row.slug === slug);
|
|
@@ -1822,30 +1881,42 @@ export class ChannelConnectorService {
|
|
|
1822
1881
|
continue;
|
|
1823
1882
|
}
|
|
1824
1883
|
if (!category) {
|
|
1825
|
-
const created = await this.
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1884
|
+
const created = await this.createTaxonomyOrAdopt(
|
|
1885
|
+
() => this.catalog.createCategory({ slug }, actor),
|
|
1886
|
+
async (id) => (await this.db.select().from(categories).where(eq(categories.id, id)))[0],
|
|
1887
|
+
async () => (await this.db.select().from(categories).where(and(eq(categories.organizationId, orgId), eq(categories.slug, slug))))[0],
|
|
1888
|
+
`Category "${slug}"`,
|
|
1889
|
+
);
|
|
1890
|
+
if (!created.ok) return created;
|
|
1891
|
+
category = created.value;
|
|
1830
1892
|
categoryRows.push(category);
|
|
1831
1893
|
}
|
|
1894
|
+
if (linkedCategories.has(category.id)) continue;
|
|
1832
1895
|
const linked = await this.catalog.addToCategory(entityId, category.id, actor);
|
|
1833
1896
|
if (!linked.ok) return PluginErr(linked.error.message);
|
|
1897
|
+
changed = true;
|
|
1834
1898
|
}
|
|
1835
1899
|
|
|
1836
1900
|
const brandRows = taxonomy.brands;
|
|
1837
1901
|
if (item.brand) {
|
|
1838
1902
|
let brand = brandRows.find((row) => row.slug === item.brand);
|
|
1839
1903
|
if (!brand) {
|
|
1840
|
-
const
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1904
|
+
const brandSlug = item.brand;
|
|
1905
|
+
const created = await this.createTaxonomyOrAdopt(
|
|
1906
|
+
() => this.catalog.createBrand({ slug: brandSlug, displayName: brandSlug }, actor),
|
|
1907
|
+
async (id) => (await this.db.select().from(brands).where(eq(brands.id, id)))[0],
|
|
1908
|
+
async () => (await this.db.select().from(brands).where(and(eq(brands.organizationId, orgId), eq(brands.slug, brandSlug))))[0],
|
|
1909
|
+
`Brand "${brandSlug}"`,
|
|
1910
|
+
);
|
|
1911
|
+
if (!created.ok) return created;
|
|
1912
|
+
brand = created.value;
|
|
1845
1913
|
brandRows.push(brand);
|
|
1846
1914
|
}
|
|
1847
|
-
|
|
1848
|
-
|
|
1915
|
+
if (!linkedBrands.has(brand.id)) {
|
|
1916
|
+
const linked = await this.catalog.addToBrand(entityId, brand.id, actor);
|
|
1917
|
+
if (!linked.ok) return PluginErr(linked.error.message);
|
|
1918
|
+
changed = true;
|
|
1919
|
+
}
|
|
1849
1920
|
}
|
|
1850
1921
|
|
|
1851
1922
|
const tagRows = taxonomy.tags;
|
|
@@ -1860,9 +1931,10 @@ export class ChannelConnectorService {
|
|
|
1860
1931
|
if (!tag) return PluginErr(`Tag "${slug}" was not persisted.`);
|
|
1861
1932
|
tagRows.push(tag);
|
|
1862
1933
|
}
|
|
1863
|
-
await this.db.insert(entityTags).values({ entityId, tagId: tag.id }).onConflictDoNothing();
|
|
1934
|
+
const added = await this.db.insert(entityTags).values({ entityId, tagId: tag.id }).onConflictDoNothing().returning({ tagId: entityTags.tagId });
|
|
1935
|
+
if (added.length > 0) changed = true;
|
|
1864
1936
|
}
|
|
1865
|
-
return Ok(
|
|
1937
|
+
return Ok({ changed });
|
|
1866
1938
|
}
|
|
1867
1939
|
|
|
1868
1940
|
private async applyMedia(
|
|
@@ -3815,14 +3887,14 @@ export class ChannelConnectorService {
|
|
|
3815
3887
|
// that item's failure, reported with its error — not a page error the caller would retry.
|
|
3816
3888
|
if (!variantIds.ok) { failures.push({ externalId: item.externalId, error: variantIds.error }); continue; }
|
|
3817
3889
|
const taxonomy = await this.applyTaxonomy(orgId, entityId, writable, actor, warnings);
|
|
3818
|
-
if (!taxonomy.ok)
|
|
3890
|
+
if (!taxonomy.ok) { failures.push({ externalId: item.externalId, error: taxonomy.error }); continue; }
|
|
3819
3891
|
const media = await this.applyMedia(orgId, entityId, writable, variantIds.value.value, actor, warnings, owners);
|
|
3820
3892
|
if (!media.ok) return media;
|
|
3821
3893
|
attributesCreated += attributes.value.created;
|
|
3822
3894
|
mediaImported += media.value.imported;
|
|
3823
3895
|
variantsGivenOptionValues += variantIds.value.repaired;
|
|
3824
3896
|
skipped.push(...media.value.skipped.map((fieldPath) => ({ entityId, fieldPath })));
|
|
3825
|
-
entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || media.value.changed || attributes.value.changed;
|
|
3897
|
+
entityTouched = entityTouched || optionAxes.value.changed || variantIds.value.changed || taxonomy.value.changed || media.value.changed || attributes.value.changed;
|
|
3826
3898
|
if (entityTouched) entitiesTouched += 1;
|
|
3827
3899
|
// Counted from what was written, not from the stored hash: a hash that moved while the
|
|
3828
3900
|
// product did not (a blanked map row, a change in how an item serialises) is not drift.
|
|
@@ -3990,6 +4062,7 @@ export class ChannelConnectorService {
|
|
|
3990
4062
|
...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
|
|
3991
4063
|
...(converged.value.conflicts.length > 0 ? { conflicts: converged.value.conflicts } : {}),
|
|
3992
4064
|
...(converged.value.warnings.length > 0 ? { warnings: converged.value.warnings } : {}),
|
|
4065
|
+
...(converged.value.failures.length > 0 ? { failures: converged.value.failures } : {}),
|
|
3993
4066
|
};
|
|
3994
4067
|
await this.db.update(connectedStores).set({
|
|
3995
4068
|
lastReconcileAt: new Date(),
|