@open-mercato/core 0.8.1-develop.7295.1.d0e0e33014 → 0.8.1-develop.7297.1.67605e74f9

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.
@@ -1,4 +1,9 @@
1
1
  import { sql } from "kysely";
2
+ import { recordIndexerError } from "@open-mercato/shared/lib/indexers/error-log";
3
+ async function searchTokensTableExists(db) {
4
+ const row = await db.selectFrom("information_schema.tables").select(sql`1`.as("one")).where("table_name", "=", "search_tokens").limit(1).executeTakeFirst();
5
+ return !!row;
6
+ }
2
7
  async function purgeOrphans(db, options) {
3
8
  const { entityType, tenantId, partitionIndex, partitionCount, startedAt } = options;
4
9
  let q = db.deleteFrom("entity_indexes").where("entity_type", "=", entityType);
@@ -16,6 +21,43 @@ async function purgeOrphans(db, options) {
16
21
  q = q.where("entity_id", "not in", options.excludeRecordIds);
17
22
  }
18
23
  await q.execute();
24
+ try {
25
+ if (!await searchTokensTableExists(db)) return;
26
+ let tokens = db.deleteFrom("search_tokens").where("entity_type", "=", entityType);
27
+ if (tenantId !== void 0) {
28
+ tokens = tenantId === null ? tokens.where("tenant_id", "is", null) : tokens.where("tenant_id", "=", tenantId);
29
+ }
30
+ if (options.organizationId !== void 0) {
31
+ tokens = options.organizationId === null ? tokens.where("organization_id", "is", null) : tokens.where("organization_id", "=", options.organizationId);
32
+ }
33
+ if (partitionIndex != null && partitionCount != null) {
34
+ tokens = tokens.where(sql`mod(abs(hashtext(entity_id::text)), ${partitionCount}) = ${partitionIndex}`);
35
+ }
36
+ tokens = tokens.where("created_at", "<", startedAt);
37
+ if (options.excludeRecordIds?.length) {
38
+ tokens = tokens.where("entity_id", "not in", options.excludeRecordIds);
39
+ }
40
+ tokens = tokens.where(sql`not exists (
41
+ select 1 from entity_indexes as surviving
42
+ where surviving.entity_type = ${entityType}
43
+ and surviving.entity_id = search_tokens.entity_id
44
+ and surviving.tenant_id is not distinct from search_tokens.tenant_id
45
+ and surviving.organization_id is not distinct from search_tokens.organization_id
46
+ )`);
47
+ await tokens.execute();
48
+ } catch (searchTokenError) {
49
+ await recordIndexerError(
50
+ { db },
51
+ {
52
+ source: "fulltext",
53
+ handler: "query_index:purge-orphans",
54
+ error: searchTokenError,
55
+ entityType,
56
+ tenantId: tenantId ?? null,
57
+ organizationId: options.organizationId ?? null
58
+ }
59
+ ).catch(() => void 0);
60
+ }
19
61
  }
20
62
  export {
21
63
  purgeOrphans
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/query_index/lib/stale.ts"],
4
- "sourcesContent": ["import { type Kysely, sql } from 'kysely'\n\ntype PurgeOrphansOptions = {\n entityType: string\n tenantId?: string | null\n organizationId?: string | null\n partitionIndex: number | null\n partitionCount: number | null\n startedAt: Date\n /**\n * Records whose index rows must be preserved even though this run did not refresh\n * them. A row that failed to be written still looks untouched to the `updated_at`\n * predicate below, so without this the purge would delete the very entries the run\n * failed to rebuild.\n */\n excludeRecordIds?: string[]\n}\n\nexport async function purgeOrphans(\n db: Kysely<any>,\n options: PurgeOrphansOptions,\n): Promise<void> {\n const { entityType, tenantId, partitionIndex, partitionCount, startedAt } = options\n let q = db.deleteFrom('entity_indexes' as any).where('entity_type' as any, '=', entityType)\n if (tenantId !== undefined) {\n q = q.where(sql<boolean>`tenant_id is not distinct from ${tenantId ?? null}`)\n }\n if (options.organizationId !== undefined) {\n q = q.where(sql<boolean>`organization_id is not distinct from ${options.organizationId ?? null}`)\n }\n if (partitionIndex != null && partitionCount != null) {\n q = q.where(sql<boolean>`mod(abs(hashtext(entity_id::text)), ${partitionCount}) = ${partitionIndex}`)\n }\n q = q.where('updated_at' as any, '<', startedAt as any)\n if (options.excludeRecordIds?.length) {\n q = q.where('entity_id' as any, 'not in', options.excludeRecordIds)\n }\n await q.execute()\n}\n"],
5
- "mappings": "AAAA,SAAsB,WAAW;AAkBjC,eAAsB,aACpB,IACA,SACe;AACf,QAAM,EAAE,YAAY,UAAU,gBAAgB,gBAAgB,UAAU,IAAI;AAC5E,MAAI,IAAI,GAAG,WAAW,gBAAuB,EAAE,MAAM,eAAsB,KAAK,UAAU;AAC1F,MAAI,aAAa,QAAW;AAC1B,QAAI,EAAE,MAAM,qCAA8C,YAAY,IAAI,EAAE;AAAA,EAC9E;AACA,MAAI,QAAQ,mBAAmB,QAAW;AACxC,QAAI,EAAE,MAAM,2CAAoD,QAAQ,kBAAkB,IAAI,EAAE;AAAA,EAClG;AACA,MAAI,kBAAkB,QAAQ,kBAAkB,MAAM;AACpD,QAAI,EAAE,MAAM,0CAAmD,cAAc,OAAO,cAAc,EAAE;AAAA,EACtG;AACA,MAAI,EAAE,MAAM,cAAqB,KAAK,SAAgB;AACtD,MAAI,QAAQ,kBAAkB,QAAQ;AACpC,QAAI,EAAE,MAAM,aAAoB,UAAU,QAAQ,gBAAgB;AAAA,EACpE;AACA,QAAM,EAAE,QAAQ;AAClB;",
4
+ "sourcesContent": ["import { type Kysely, sql } from 'kysely'\nimport { recordIndexerError } from '@open-mercato/shared/lib/indexers/error-log'\n\ntype PurgeOrphansOptions = {\n entityType: string\n tenantId?: string | null\n organizationId?: string | null\n partitionIndex: number | null\n partitionCount: number | null\n startedAt: Date\n /**\n * Records whose index rows must be preserved even though this run did not refresh\n * them. A row that failed to be written still looks untouched to the `updated_at`\n * predicate below, so without this the purge would delete the very entries the run\n * failed to rebuild.\n */\n excludeRecordIds?: string[]\n}\n\n/**\n * `entity_indexes` ships in an earlier migration than `search_tokens`, so a deployment\n * running this code against a not-yet-migrated database has the projection table and not\n * the token table. The query engine probes the same way before reading the table\n * (`shared/lib/search/availability.ts`).\n */\nasync function searchTokensTableExists(db: Kysely<any>): Promise<boolean> {\n const row = await db\n .selectFrom('information_schema.tables' as any)\n .select(sql<number>`1`.as('one') as any)\n .where('table_name' as any, '=', 'search_tokens')\n .limit(1)\n .executeTakeFirst()\n return !!row\n}\n\nexport async function purgeOrphans(\n db: Kysely<any>,\n options: PurgeOrphansOptions,\n): Promise<void> {\n const { entityType, tenantId, partitionIndex, partitionCount, startedAt } = options\n\n let q = db.deleteFrom('entity_indexes' as any).where('entity_type' as any, '=', entityType)\n if (tenantId !== undefined) {\n q = q.where(sql<boolean>`tenant_id is not distinct from ${tenantId ?? null}`)\n }\n if (options.organizationId !== undefined) {\n q = q.where(sql<boolean>`organization_id is not distinct from ${options.organizationId ?? null}`)\n }\n if (partitionIndex != null && partitionCount != null) {\n q = q.where(sql<boolean>`mod(abs(hashtext(entity_id::text)), ${partitionCount}) = ${partitionIndex}`)\n }\n q = q.where('updated_at' as any, '<', startedAt as any)\n if (options.excludeRecordIds?.length) {\n q = q.where('entity_id' as any, 'not in', options.excludeRecordIds)\n }\n await q.execute()\n\n // The token sweep runs as its own statement, after the projection delete has committed, and\n // deliberately cannot fail the run. Its anti-join only needs those deletes to be *visible*,\n // and a committed delete is as visible as an uncommitted one inside a shared transaction \u2014\n // so wrapping the pair buys no correctness, only a new fatal path: `reindexer.ts` does not\n // guard this call and marks the whole job failed on a throw, so a statement timeout on a\n // large token delete would discard a rebuild whose projections all wrote successfully. The\n // fallback is the pre-fix steady state: orphan tokens survive, benign, until the next run\n // sweeps them. This matches how the module already treats token-side failures \u2014\n // `batch.ts` records a failed token write via `recordIndexerError` rather than counting it\n // as a write failure, and `indexer.ts` swallows the token delete outside a transaction.\n try {\n if (!(await searchTokensTableExists(db))) return\n\n // Search tokens are written from the same (entity_type, entity_id, tenant_id,\n // organization_id) tuple as the projection, but nothing above ever touched them, so\n // every record this run stopped covering kept a queryable token with no presenter \u2014\n // and token-only orphans left by earlier runs survived every rebuild since.\n let tokens = db.deleteFrom('search_tokens' as any).where('entity_type' as any, '=', entityType)\n // `= / is null` rather than the projection sweep's `is not distinct from`: these are bound\n // constants, so the two spellings are semantically identical here, but only the former can\n // serve as a btree index condition. Without it the delete uses just the `entity_type` prefix\n // of `search_tokens_presence_idx (entity_type, tenant_id, organization_id)` and a\n // tenant-scoped, unpartitioned run reads every tenant's tokens for that entity type \u2014\n // the same planner trap `shared/lib/search/availability.ts` documents for this table (#4723).\n // The anti-join below keeps the null-safe form: it compares columns, not constants.\n if (tenantId !== undefined) {\n tokens = tenantId === null\n ? tokens.where('tenant_id' as any, 'is', null)\n : tokens.where('tenant_id' as any, '=', tenantId)\n }\n if (options.organizationId !== undefined) {\n tokens = options.organizationId === null\n ? tokens.where('organization_id' as any, 'is', null)\n : tokens.where('organization_id' as any, '=', options.organizationId)\n }\n if (partitionIndex != null && partitionCount != null) {\n tokens = tokens.where(sql<boolean>`mod(abs(hashtext(entity_id::text)), ${partitionCount}) = ${partitionIndex}`)\n }\n // Secondary guard only. `replaceSearchTokensForBatch` returns early for a record whose\n // tokens are unchanged, so a perfectly current record keeps tokens older than this run\n // \u2014 a timestamp-driven sweep would make live records unfindable. What this predicate\n // does earn is the concurrent-writer case: a batch inserts tokens before the matching\n // projection, so a token written during the run can outrun its own presenter.\n tokens = tokens.where('created_at' as any, '<', startedAt as any)\n if (options.excludeRecordIds?.length) {\n tokens = tokens.where('entity_id' as any, 'not in', options.excludeRecordIds)\n }\n // The load-bearing predicate: a token survives while any projection for its own record\n // and scope survives, soft-deleted ones included. `search_tokens` has a second writer \u2014\n // `@open-mercato/search`'s token strategy writes rows for the same records under its own\n // `field` spellings \u2014 and this sweep is safe for those rows only because that strategy\n // draws its record set from the query engine, i.e. from the very projections consulted\n // here. Changing where that strategy gets its records would make this delete eat its rows.\n tokens = tokens.where(sql<boolean>`not exists (\n select 1 from entity_indexes as surviving\n where surviving.entity_type = ${entityType}\n and surviving.entity_id = search_tokens.entity_id\n and surviving.tenant_id is not distinct from search_tokens.tenant_id\n and surviving.organization_id is not distinct from search_tokens.organization_id\n )`)\n await tokens.execute()\n } catch (searchTokenError) {\n await recordIndexerError(\n { db },\n {\n source: 'fulltext',\n handler: 'query_index:purge-orphans',\n error: searchTokenError,\n entityType,\n tenantId: tenantId ?? null,\n organizationId: options.organizationId ?? null,\n },\n ).catch(() => undefined)\n }\n}\n"],
5
+ "mappings": "AAAA,SAAsB,WAAW;AACjC,SAAS,0BAA0B;AAwBnC,eAAe,wBAAwB,IAAmC;AACxE,QAAM,MAAM,MAAM,GACf,WAAW,2BAAkC,EAC7C,OAAO,OAAe,GAAG,KAAK,CAAQ,EACtC,MAAM,cAAqB,KAAK,eAAe,EAC/C,MAAM,CAAC,EACP,iBAAiB;AACpB,SAAO,CAAC,CAAC;AACX;AAEA,eAAsB,aACpB,IACA,SACe;AACf,QAAM,EAAE,YAAY,UAAU,gBAAgB,gBAAgB,UAAU,IAAI;AAE5E,MAAI,IAAI,GAAG,WAAW,gBAAuB,EAAE,MAAM,eAAsB,KAAK,UAAU;AAC1F,MAAI,aAAa,QAAW;AAC1B,QAAI,EAAE,MAAM,qCAA8C,YAAY,IAAI,EAAE;AAAA,EAC9E;AACA,MAAI,QAAQ,mBAAmB,QAAW;AACxC,QAAI,EAAE,MAAM,2CAAoD,QAAQ,kBAAkB,IAAI,EAAE;AAAA,EAClG;AACA,MAAI,kBAAkB,QAAQ,kBAAkB,MAAM;AACpD,QAAI,EAAE,MAAM,0CAAmD,cAAc,OAAO,cAAc,EAAE;AAAA,EACtG;AACA,MAAI,EAAE,MAAM,cAAqB,KAAK,SAAgB;AACtD,MAAI,QAAQ,kBAAkB,QAAQ;AACpC,QAAI,EAAE,MAAM,aAAoB,UAAU,QAAQ,gBAAgB;AAAA,EACpE;AACA,QAAM,EAAE,QAAQ;AAYhB,MAAI;AACF,QAAI,CAAE,MAAM,wBAAwB,EAAE,EAAI;AAM1C,QAAI,SAAS,GAAG,WAAW,eAAsB,EAAE,MAAM,eAAsB,KAAK,UAAU;AAQ9F,QAAI,aAAa,QAAW;AAC1B,eAAS,aAAa,OAClB,OAAO,MAAM,aAAoB,MAAM,IAAI,IAC3C,OAAO,MAAM,aAAoB,KAAK,QAAQ;AAAA,IACpD;AACA,QAAI,QAAQ,mBAAmB,QAAW;AACxC,eAAS,QAAQ,mBAAmB,OAChC,OAAO,MAAM,mBAA0B,MAAM,IAAI,IACjD,OAAO,MAAM,mBAA0B,KAAK,QAAQ,cAAc;AAAA,IACxE;AACA,QAAI,kBAAkB,QAAQ,kBAAkB,MAAM;AACpD,eAAS,OAAO,MAAM,0CAAmD,cAAc,OAAO,cAAc,EAAE;AAAA,IAChH;AAMA,aAAS,OAAO,MAAM,cAAqB,KAAK,SAAgB;AAChE,QAAI,QAAQ,kBAAkB,QAAQ;AACpC,eAAS,OAAO,MAAM,aAAoB,UAAU,QAAQ,gBAAgB;AAAA,IAC9E;AAOA,aAAS,OAAO,MAAM;AAAA;AAAA,sCAEY,UAAU;AAAA;AAAA;AAAA;AAAA,MAI1C;AACF,UAAM,OAAO,QAAQ;AAAA,EACvB,SAAS,kBAAkB;AACzB,UAAM;AAAA,MACJ,EAAE,GAAG;AAAA,MACL;AAAA,QACE,QAAQ;AAAA,QACR,SAAS;AAAA,QACT,OAAO;AAAA,QACP;AAAA,QACA,UAAU,YAAY;AAAA,QACtB,gBAAgB,QAAQ,kBAAkB;AAAA,MAC5C;AAAA,IACF,EAAE,MAAM,MAAM,MAAS;AAAA,EACzB;AACF;",
6
6
  "names": []
7
7
  }
@@ -460,11 +460,12 @@ async function executeActivityByType(em, container, activity, context, signal) {
460
460
  signal
461
461
  });
462
462
  }
463
- async function executeSendEmail(config, context, container) {
463
+ async function executeSendEmail(config, context, container, signal) {
464
464
  const { to, subject, template, templateData, body } = config;
465
465
  if (!to || !subject) {
466
466
  throw new Error('SEND_EMAIL requires "to" and "subject" fields');
467
467
  }
468
+ throwIfAborted(signal);
468
469
  logger.info("Send email activity invoked", { component: "SEND_EMAIL", subject });
469
470
  let emailService;
470
471
  try {
@@ -473,19 +474,26 @@ async function executeSendEmail(config, context, container) {
473
474
  emailService = void 0;
474
475
  }
475
476
  if (emailService && typeof emailService.send === "function") {
476
- await emailService.send({
477
- to,
478
- subject,
479
- template,
480
- templateData,
481
- body
482
- });
477
+ await raceAbortable(
478
+ Promise.resolve(
479
+ emailService.send({
480
+ to,
481
+ subject,
482
+ template,
483
+ templateData,
484
+ body,
485
+ signal
486
+ })
487
+ ),
488
+ signal
489
+ );
490
+ throwIfAborted(signal);
483
491
  return { sent: true, to, subject, via: "emailService" };
484
492
  }
485
493
  logger.warn("SEND_EMAIL has no registered email service; email was not sent", { component: "SEND_EMAIL", subject });
486
494
  return { sent: false, simulated: true, to, subject, via: "console", reason: "no-email-service" };
487
495
  }
488
- async function executeEmitEvent(config, context, container) {
496
+ async function executeEmitEvent(config, context, container, signal) {
489
497
  const { eventName, payload } = config;
490
498
  if (!eventName) {
491
499
  throw new Error('EMIT_EVENT requires "eventName" field');
@@ -499,6 +507,7 @@ async function executeEmitEvent(config, context, container) {
499
507
  `EMIT_EVENT payload contains unresolved template variables for: ${unresolvedPayloadKeys.join(", ")}. Check that the workflow context provides these keys.`
500
508
  );
501
509
  }
510
+ throwIfAborted(signal);
502
511
  const eventBus = container.resolve("eventBus");
503
512
  if (!eventBus || typeof eventBus.emitEvent !== "function") {
504
513
  throw new Error("Event bus not available in container");
@@ -512,10 +521,16 @@ async function executeEmitEvent(config, context, container) {
512
521
  organizationId: context.workflowInstance.organizationId
513
522
  }
514
523
  };
515
- await eventBus.emitEvent(eventName, enrichedPayload, {
516
- tenantId: context.workflowInstance.tenantId,
517
- organizationId: context.workflowInstance.organizationId
518
- });
524
+ await raceAbortable(
525
+ Promise.resolve(
526
+ eventBus.emitEvent(eventName, enrichedPayload, {
527
+ tenantId: context.workflowInstance.tenantId,
528
+ organizationId: context.workflowInstance.organizationId
529
+ })
530
+ ),
531
+ signal
532
+ );
533
+ throwIfAborted(signal);
519
534
  return { emitted: true, eventName, payload: enrichedPayload };
520
535
  }
521
536
  const UNRESOLVED_TEMPLATE_PATTERN = /\{\{[^}]+\}\}/;
@@ -533,7 +548,7 @@ function findUnresolvedTemplateKeys(value, path = "") {
533
548
  }
534
549
  return [];
535
550
  }
536
- async function executeUpdateEntity(em, config, context, container) {
551
+ async function executeUpdateEntity(em, config, context, container, signal) {
537
552
  const { commandId, input, statusDictionary } = config;
538
553
  if (!commandId) {
539
554
  throw new Error('UPDATE_ENTITY requires "commandId" field (e.g., "sales.documents.update")');
@@ -546,6 +561,7 @@ async function executeUpdateEntity(em, config, context, container) {
546
561
  container,
547
562
  context.workflowInstance.tenantId
548
563
  );
564
+ throwIfAborted(signal);
549
565
  if (!isWorkflowCommandEnabled(workflowSafeCommand, commandPolicy)) {
550
566
  throw new Error("UPDATE_ENTITY command is not enabled for this tenant");
551
567
  }
@@ -563,6 +579,7 @@ async function executeUpdateEntity(em, config, context, container) {
563
579
  context.workflowInstance.tenantId,
564
580
  context.workflowInstance.organizationId
565
581
  );
582
+ throwIfAborted(signal);
566
583
  if (!authorized) {
567
584
  throw new Error("UPDATE_ENTITY command is not authorized");
568
585
  }
@@ -585,6 +602,7 @@ async function executeUpdateEntity(em, config, context, container) {
585
602
  context.workflowInstance.tenantId,
586
603
  context.workflowInstance.organizationId
587
604
  );
605
+ throwIfAborted(signal);
588
606
  if (statusEntryId) {
589
607
  finalInput.statusEntryId = statusEntryId;
590
608
  }
@@ -602,10 +620,17 @@ async function executeUpdateEntity(em, config, context, container) {
602
620
  selectedOrganizationId: context.workflowInstance.organizationId,
603
621
  organizationIds: context.workflowInstance.organizationId ? [context.workflowInstance.organizationId] : null
604
622
  };
605
- const { result, logEntry } = await commandBus.execute(commandId, {
606
- input: finalInput,
607
- ctx
608
- });
623
+ throwIfAborted(signal);
624
+ const { result, logEntry } = await raceAbortable(
625
+ Promise.resolve(
626
+ commandBus.execute(commandId, {
627
+ input: finalInput,
628
+ ctx
629
+ })
630
+ ),
631
+ signal
632
+ );
633
+ throwIfAborted(signal);
609
634
  return {
610
635
  executed: true,
611
636
  commandId,
@@ -705,18 +730,23 @@ async function executeCallWebhook(config, context, deps = {}) {
705
730
  result
706
731
  };
707
732
  }
708
- async function executeFunction(config, context, container) {
733
+ async function executeFunction(config, context, container, signal) {
709
734
  const { functionName, args = {} } = config;
710
735
  if (!functionName) {
711
736
  throw new Error('EXECUTE_FUNCTION requires "functionName" field');
712
737
  }
738
+ throwIfAborted(signal);
713
739
  const fnKey = `workflowFunction:${functionName}`;
714
740
  try {
715
741
  const fn = container.resolve(fnKey);
716
742
  if (typeof fn !== "function") {
717
743
  throw new Error(`Registered workflow function "${functionName}" is not a function`);
718
744
  }
719
- const result = await fn(args, context);
745
+ const result = await raceAbortable(
746
+ Promise.resolve(fn(args, context, signal)),
747
+ signal
748
+ );
749
+ throwIfAborted(signal);
720
750
  return { executed: true, functionName, result };
721
751
  } catch (error) {
722
752
  if (error instanceof Error && error.message.includes("not registered")) {
@@ -1168,6 +1198,43 @@ async function executeWithTimeout(executor, timeoutMs) {
1168
1198
  clearTimeout(timeoutId);
1169
1199
  }
1170
1200
  }
1201
+ function createActivityAbortError(signal) {
1202
+ if (signal?.reason instanceof Error) return signal.reason;
1203
+ try {
1204
+ return new DOMException("The operation was aborted", "AbortError");
1205
+ } catch {
1206
+ const error = new Error("The operation was aborted");
1207
+ error.name = "AbortError";
1208
+ return error;
1209
+ }
1210
+ }
1211
+ function throwIfAborted(signal) {
1212
+ if (signal?.aborted) {
1213
+ throw createActivityAbortError(signal);
1214
+ }
1215
+ }
1216
+ function raceAbortable(promise, signal) {
1217
+ if (!signal) return promise;
1218
+ if (signal.aborted) {
1219
+ return Promise.reject(createActivityAbortError(signal));
1220
+ }
1221
+ return new Promise((resolve, reject) => {
1222
+ const onAbort = () => {
1223
+ reject(createActivityAbortError(signal));
1224
+ };
1225
+ signal.addEventListener("abort", onAbort, { once: true });
1226
+ promise.then(
1227
+ (value) => {
1228
+ signal.removeEventListener("abort", onAbort);
1229
+ resolve(value);
1230
+ },
1231
+ (error) => {
1232
+ signal.removeEventListener("abort", onAbort);
1233
+ reject(error);
1234
+ }
1235
+ );
1236
+ });
1237
+ }
1171
1238
  bindActivityExecutor({
1172
1239
  executeSendEmail,
1173
1240
  executeEmitEvent,