@unifiedcommerce/core 0.2.0 → 0.2.1

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.
Files changed (186) hide show
  1. package/package.json +2 -1
  2. package/src/adapters/console-email.ts +43 -0
  3. package/src/auth/access.ts +187 -0
  4. package/src/auth/auth-schema.ts +139 -0
  5. package/src/auth/middleware.ts +161 -0
  6. package/src/auth/org.ts +41 -0
  7. package/src/auth/permissions.ts +28 -0
  8. package/src/auth/setup.ts +171 -0
  9. package/src/auth/system-actor.ts +19 -0
  10. package/src/auth/types.ts +10 -0
  11. package/src/config/defaults.ts +82 -0
  12. package/src/config/define-config.ts +53 -0
  13. package/src/config/types.ts +301 -0
  14. package/src/generated/plugin-capabilities.d.ts +20 -0
  15. package/src/generated/plugin-manifest.ts +23 -0
  16. package/src/generated/plugin-repositories.d.ts +20 -0
  17. package/src/hooks/checkout-completion.ts +262 -0
  18. package/src/hooks/checkout.ts +677 -0
  19. package/src/hooks/order-emails.ts +62 -0
  20. package/src/index.ts +215 -0
  21. package/src/interfaces/mcp/agent-prompt.ts +174 -0
  22. package/src/interfaces/mcp/context-enrichment.ts +177 -0
  23. package/src/interfaces/mcp/server.ts +47 -0
  24. package/src/interfaces/mcp/tool-builder.ts +261 -0
  25. package/src/interfaces/mcp/tools/analytics.ts +76 -0
  26. package/src/interfaces/mcp/tools/cart.ts +57 -0
  27. package/src/interfaces/mcp/tools/catalog.ts +299 -0
  28. package/src/interfaces/mcp/tools/index.ts +22 -0
  29. package/src/interfaces/mcp/tools/inventory.ts +161 -0
  30. package/src/interfaces/mcp/tools/orders.ts +104 -0
  31. package/src/interfaces/mcp/tools/pricing.ts +94 -0
  32. package/src/interfaces/mcp/tools/promotions.ts +106 -0
  33. package/src/interfaces/mcp/tools/registry.ts +101 -0
  34. package/src/interfaces/mcp/tools/search.ts +42 -0
  35. package/src/interfaces/mcp/tools/webhooks.ts +48 -0
  36. package/src/interfaces/mcp/transport.ts +128 -0
  37. package/src/interfaces/rest/customer-portal.ts +299 -0
  38. package/src/interfaces/rest/index.ts +74 -0
  39. package/src/interfaces/rest/router.ts +333 -0
  40. package/src/interfaces/rest/routes/admin-jobs.ts +58 -0
  41. package/src/interfaces/rest/routes/audit.ts +50 -0
  42. package/src/interfaces/rest/routes/carts.ts +89 -0
  43. package/src/interfaces/rest/routes/catalog.ts +493 -0
  44. package/src/interfaces/rest/routes/checkout.ts +284 -0
  45. package/src/interfaces/rest/routes/inventory.ts +70 -0
  46. package/src/interfaces/rest/routes/media.ts +86 -0
  47. package/src/interfaces/rest/routes/orders.ts +78 -0
  48. package/src/interfaces/rest/routes/payments.ts +60 -0
  49. package/src/interfaces/rest/routes/pricing.ts +57 -0
  50. package/src/interfaces/rest/routes/promotions.ts +93 -0
  51. package/src/interfaces/rest/routes/search.ts +71 -0
  52. package/src/interfaces/rest/routes/webhooks.ts +46 -0
  53. package/src/interfaces/rest/schemas/admin-jobs.ts +40 -0
  54. package/src/interfaces/rest/schemas/audit.ts +46 -0
  55. package/src/interfaces/rest/schemas/carts.ts +125 -0
  56. package/src/interfaces/rest/schemas/catalog.ts +450 -0
  57. package/src/interfaces/rest/schemas/checkout.ts +66 -0
  58. package/src/interfaces/rest/schemas/customer-portal.ts +195 -0
  59. package/src/interfaces/rest/schemas/inventory.ts +138 -0
  60. package/src/interfaces/rest/schemas/media.ts +75 -0
  61. package/src/interfaces/rest/schemas/orders.ts +104 -0
  62. package/src/interfaces/rest/schemas/pricing.ts +80 -0
  63. package/src/interfaces/rest/schemas/promotions.ts +110 -0
  64. package/src/interfaces/rest/schemas/responses.ts +85 -0
  65. package/src/interfaces/rest/schemas/search.ts +58 -0
  66. package/src/interfaces/rest/schemas/shared.ts +62 -0
  67. package/src/interfaces/rest/schemas/webhooks.ts +68 -0
  68. package/src/interfaces/rest/utils.ts +104 -0
  69. package/src/interfaces/rest/webhook-router.ts +50 -0
  70. package/src/kernel/compensation/executor.ts +61 -0
  71. package/src/kernel/compensation/types.ts +26 -0
  72. package/src/kernel/database/adapter.ts +21 -0
  73. package/src/kernel/database/drizzle-db.ts +56 -0
  74. package/src/kernel/database/migrate.ts +76 -0
  75. package/src/kernel/database/plugin-types.ts +34 -0
  76. package/src/kernel/database/schema.ts +49 -0
  77. package/src/kernel/database/scoped-db.ts +68 -0
  78. package/src/kernel/database/tx-context.ts +46 -0
  79. package/src/kernel/error-mapper.ts +15 -0
  80. package/src/kernel/errors.ts +89 -0
  81. package/src/kernel/factory/repository-factory.ts +244 -0
  82. package/src/kernel/hooks/create-context.ts +43 -0
  83. package/src/kernel/hooks/executor.ts +88 -0
  84. package/src/kernel/hooks/registry.ts +74 -0
  85. package/src/kernel/hooks/types.ts +52 -0
  86. package/src/kernel/http-error.ts +44 -0
  87. package/src/kernel/jobs/adapter.ts +36 -0
  88. package/src/kernel/jobs/drizzle-adapter.ts +58 -0
  89. package/src/kernel/jobs/runner.ts +153 -0
  90. package/src/kernel/jobs/schema.ts +46 -0
  91. package/src/kernel/jobs/types.ts +30 -0
  92. package/src/kernel/local-api.ts +187 -0
  93. package/src/kernel/plugin/manifest.ts +271 -0
  94. package/src/kernel/query/executor.ts +184 -0
  95. package/src/kernel/query/registry.ts +46 -0
  96. package/src/kernel/result.ts +33 -0
  97. package/src/kernel/schema/extra-columns.ts +37 -0
  98. package/src/kernel/service-registry.ts +76 -0
  99. package/src/kernel/service-timing.ts +89 -0
  100. package/src/kernel/state-machine/machine.ts +101 -0
  101. package/src/modules/analytics/drizzle-adapter.ts +426 -0
  102. package/src/modules/analytics/hooks.ts +11 -0
  103. package/src/modules/analytics/models.ts +125 -0
  104. package/src/modules/analytics/repository/index.ts +6 -0
  105. package/src/modules/analytics/service.ts +245 -0
  106. package/src/modules/analytics/types.ts +180 -0
  107. package/src/modules/audit/hooks.ts +78 -0
  108. package/src/modules/audit/schema.ts +33 -0
  109. package/src/modules/audit/service.ts +151 -0
  110. package/src/modules/cart/access.ts +27 -0
  111. package/src/modules/cart/matcher.ts +26 -0
  112. package/src/modules/cart/repository/index.ts +234 -0
  113. package/src/modules/cart/schema.ts +42 -0
  114. package/src/modules/cart/schemas.ts +38 -0
  115. package/src/modules/cart/service.ts +541 -0
  116. package/src/modules/catalog/repository/index.ts +772 -0
  117. package/src/modules/catalog/schema.ts +203 -0
  118. package/src/modules/catalog/schemas.ts +104 -0
  119. package/src/modules/catalog/service.ts +1544 -0
  120. package/src/modules/customers/repository/index.ts +327 -0
  121. package/src/modules/customers/schema.ts +64 -0
  122. package/src/modules/customers/service.ts +171 -0
  123. package/src/modules/fulfillment/repository/index.ts +426 -0
  124. package/src/modules/fulfillment/schema.ts +101 -0
  125. package/src/modules/fulfillment/service.ts +555 -0
  126. package/src/modules/fulfillment/types.ts +59 -0
  127. package/src/modules/inventory/repository/index.ts +509 -0
  128. package/src/modules/inventory/schema.ts +94 -0
  129. package/src/modules/inventory/schemas.ts +38 -0
  130. package/src/modules/inventory/service.ts +490 -0
  131. package/src/modules/media/adapter.ts +17 -0
  132. package/src/modules/media/repository/index.ts +274 -0
  133. package/src/modules/media/schema.ts +41 -0
  134. package/src/modules/media/service.ts +151 -0
  135. package/src/modules/orders/repository/index.ts +287 -0
  136. package/src/modules/orders/schema.ts +66 -0
  137. package/src/modules/orders/service.ts +619 -0
  138. package/src/modules/orders/stale-order-cleanup.ts +76 -0
  139. package/src/modules/organization/service.ts +191 -0
  140. package/src/modules/payments/adapter.ts +47 -0
  141. package/src/modules/payments/repository/index.ts +6 -0
  142. package/src/modules/payments/service.ts +107 -0
  143. package/src/modules/pricing/repository/index.ts +291 -0
  144. package/src/modules/pricing/schema.ts +71 -0
  145. package/src/modules/pricing/schemas.ts +38 -0
  146. package/src/modules/pricing/service.ts +494 -0
  147. package/src/modules/promotions/repository/index.ts +325 -0
  148. package/src/modules/promotions/schema.ts +62 -0
  149. package/src/modules/promotions/schemas.ts +38 -0
  150. package/src/modules/promotions/service.ts +598 -0
  151. package/src/modules/search/adapter.ts +57 -0
  152. package/src/modules/search/hooks.ts +12 -0
  153. package/src/modules/search/repository/index.ts +6 -0
  154. package/src/modules/search/service.ts +315 -0
  155. package/src/modules/shipping/calculator.ts +188 -0
  156. package/src/modules/shipping/repository/index.ts +6 -0
  157. package/src/modules/shipping/service.ts +51 -0
  158. package/src/modules/tax/adapter.ts +60 -0
  159. package/src/modules/tax/repository/index.ts +6 -0
  160. package/src/modules/tax/service.ts +53 -0
  161. package/src/modules/webhooks/hook.ts +34 -0
  162. package/src/modules/webhooks/repository/index.ts +278 -0
  163. package/src/modules/webhooks/schema.ts +56 -0
  164. package/src/modules/webhooks/service.ts +117 -0
  165. package/src/modules/webhooks/signing.ts +6 -0
  166. package/src/modules/webhooks/ssrf-guard.ts +71 -0
  167. package/src/modules/webhooks/tasks.ts +52 -0
  168. package/src/modules/webhooks/worker.ts +134 -0
  169. package/src/runtime/commerce.ts +145 -0
  170. package/src/runtime/kernel.ts +426 -0
  171. package/src/runtime/logger.ts +36 -0
  172. package/src/runtime/server.ts +355 -0
  173. package/src/runtime/shutdown.ts +43 -0
  174. package/src/test-utils/create-pglite-adapter.ts +129 -0
  175. package/src/test-utils/create-plugin-test-app.ts +128 -0
  176. package/src/test-utils/create-repository-test-harness.ts +16 -0
  177. package/src/test-utils/create-test-config.ts +190 -0
  178. package/src/test-utils/create-test-kernel.ts +7 -0
  179. package/src/test-utils/create-test-plugin-context.ts +75 -0
  180. package/src/test-utils/rest-api-test-utils.ts +265 -0
  181. package/src/test-utils/test-actors.ts +62 -0
  182. package/src/test-utils/typed-hooks.ts +54 -0
  183. package/src/types/commerce-types.ts +34 -0
  184. package/src/utils/id.ts +3 -0
  185. package/src/utils/logger.ts +18 -0
  186. package/src/utils/pagination.ts +22 -0
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Canonical database types for plugin service constructors.
3
+ *
4
+ * Replaces the copy-pasted type definitions in every plugin's types.ts:
5
+ * type Db = PgDatabase<PgQueryResultHKT, Record<string, unknown>>
6
+ *
7
+ * Import from core instead:
8
+ * import type { PluginDb, PluginTxFn } from "@unifiedcommerce/core";
9
+ */
10
+
11
+ import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";
12
+
13
+ /**
14
+ * Database instance type for plugin services.
15
+ * This is the Drizzle PgDatabase with an opaque schema record.
16
+ */
17
+ export type PluginDb = PgDatabase<PgQueryResultHKT, Record<string, unknown>>;
18
+
19
+ /**
20
+ * Transaction function type for plugin services that need
21
+ * transactional guarantees (e.g., POS transaction complete,
22
+ * gift card debit, inventory reservation).
23
+ *
24
+ * Usage:
25
+ * constructor(private db: PluginDb, private txFn: PluginTxFn) {}
26
+ *
27
+ * async doWork() {
28
+ * return this.txFn(async (tx) => {
29
+ * await tx.insert(...).values(...);
30
+ * await tx.update(...).set(...);
31
+ * });
32
+ * }
33
+ */
34
+ export type PluginTxFn = <T>(fn: (tx: PluginDb) => Promise<T>) => Promise<T>;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Combined schema barrel for Drizzle.
3
+ *
4
+ * Single source of truth for all core table definitions. Both drizzle-kit
5
+ * (via drizzle.config.ts) and the TypeScript type system consume this file.
6
+ *
7
+ * Drizzle-kit resolves re-exports, so pointing the config schema option
8
+ * at this one file discovers every core table. Plugin schemas live in
9
+ * their own packages and are referenced via glob patterns.
10
+ */
11
+
12
+ // Auth (Better Auth generated tables)
13
+ export * from "../../auth/auth-schema.js";
14
+
15
+ // Catalog module
16
+ export * from "../../modules/catalog/schema.js";
17
+
18
+ // Inventory module
19
+ export * from "../../modules/inventory/schema.js";
20
+
21
+ // Cart module
22
+ export * from "../../modules/cart/schema.js";
23
+
24
+ // Orders module
25
+ export * from "../../modules/orders/schema.js";
26
+
27
+ // Customers module
28
+ export * from "../../modules/customers/schema.js";
29
+
30
+ // Pricing module
31
+ export * from "../../modules/pricing/schema.js";
32
+
33
+ // Promotions module
34
+ export * from "../../modules/promotions/schema.js";
35
+
36
+ // Media module
37
+ export * from "../../modules/media/schema.js";
38
+
39
+ // Webhooks module
40
+ export * from "../../modules/webhooks/schema.js";
41
+
42
+ // Fulfillment module
43
+ export * from "../../modules/fulfillment/schema.js";
44
+
45
+ // Jobs (kernel)
46
+ export * from "../jobs/schema.js";
47
+
48
+ // Audit module
49
+ export * from "../../modules/audit/schema.js";
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Scoped DB Proxy
3
+ *
4
+ * Wraps a Drizzle PgDatabase instance so that INSERT operations on
5
+ * org-scoped tables automatically include the actor's organizationId.
6
+ *
7
+ * Plugin route handlers receive this scoped db via the router() builder.
8
+ * They write normal Drizzle inserts without ever mentioning organizationId:
9
+ *
10
+ * const [card] = await db.insert(giftCards)
11
+ * .values({ code: "ABC", balance: 5000 })
12
+ * .returning();
13
+ * // organizationId is auto-set from the actor context
14
+ *
15
+ * SELECT/UPDATE/DELETE scoping is handled at the repository/service layer
16
+ * via resolveOrgId(). The proxy focuses on INSERT auto-stamping because
17
+ * that is the operation most commonly forgotten by plugin developers.
18
+ */
19
+
20
+ import { getTableColumns } from "drizzle-orm";
21
+ import type { PgTable } from "drizzle-orm/pg-core";
22
+
23
+ function isOrgScoped(table: PgTable): boolean {
24
+ const columns = getTableColumns(table);
25
+ return "organizationId" in columns;
26
+ }
27
+
28
+ export function createScopedDb<TDb>(rawDb: TDb, organizationId: string): TDb {
29
+ if (!rawDb || typeof rawDb !== "object") return rawDb;
30
+
31
+ return new Proxy(rawDb as Record<string, unknown>, {
32
+ get(target, prop, receiver) {
33
+ const value = Reflect.get(target, prop, receiver);
34
+
35
+ if (prop === "insert" && typeof value === "function") {
36
+ return (table: PgTable) => {
37
+ const builder = value.call(target, table);
38
+ if (!isOrgScoped(table)) return builder;
39
+
40
+ // Wrap .values() to auto-inject organizationId
41
+ const originalValues = (builder as Record<string, unknown>).values;
42
+ if (typeof originalValues !== "function") return builder;
43
+
44
+ return new Proxy(builder as Record<string, unknown>, {
45
+ get(t, p, r) {
46
+ if (p === "values") {
47
+ return (data: unknown) => {
48
+ const stamp = (row: Record<string, unknown>) => ({
49
+ ...row,
50
+ organizationId,
51
+ });
52
+ const stamped = Array.isArray(data)
53
+ ? data.map(stamp)
54
+ : stamp(data as Record<string, unknown>);
55
+ return originalValues.call(t, stamped);
56
+ };
57
+ }
58
+ const v = Reflect.get(t, p, r);
59
+ return typeof v === "function" ? v.bind(t) : v;
60
+ },
61
+ });
62
+ };
63
+ }
64
+
65
+ return typeof value === "function" ? value.bind(target) : value;
66
+ },
67
+ }) as TDb;
68
+ }
@@ -0,0 +1,46 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { Actor } from "../../auth/types.js";
3
+ import type { DatabaseAdapter } from "./adapter.js";
4
+
5
+ export interface TxContext<TTx = unknown> {
6
+ tx: TTx;
7
+ actor: Actor | null;
8
+ requestId: string;
9
+ }
10
+
11
+ export interface WithTransactionOptions {
12
+ actor: Actor | null;
13
+ requestId?: string;
14
+ }
15
+
16
+ export function createTxContext<TTx>(
17
+ tx: TTx,
18
+ options: WithTransactionOptions,
19
+ ): TxContext<TTx> {
20
+ return {
21
+ tx,
22
+ actor: options.actor,
23
+ requestId: options.requestId ?? randomUUID(),
24
+ };
25
+ }
26
+
27
+ export async function withTransaction<TDb, TTx, TResult>(
28
+ database: DatabaseAdapter<TDb, TTx>,
29
+ options: WithTransactionOptions,
30
+ fn: (ctx: TxContext<TTx>) => Promise<TResult>,
31
+ ): Promise<TResult> {
32
+ return database.transaction(async (tx) => {
33
+ return fn(createTxContext(tx, options));
34
+ });
35
+ }
36
+
37
+ export function reuseOrCreateTxContext<TTx>(
38
+ tx: TTx,
39
+ options: WithTransactionOptions,
40
+ existing?: TxContext<TTx> | null,
41
+ ): TxContext<TTx> {
42
+ if (existing) {
43
+ return existing;
44
+ }
45
+ return createTxContext(tx, options);
46
+ }
@@ -0,0 +1,15 @@
1
+ import { toCommerceError } from "./errors.js";
2
+ import type { ContentfulStatusCode } from "hono/utils/http-status";
3
+
4
+ const statusByCode: Record<string, ContentfulStatusCode> = {
5
+ NOT_FOUND: 404,
6
+ VALIDATION_FAILED: 422,
7
+ FORBIDDEN: 403,
8
+ CONFLICT: 409,
9
+ INVALID_TRANSITION: 422,
10
+ };
11
+
12
+ export function mapErrorToStatus(error: unknown): ContentfulStatusCode {
13
+ const normalized = toCommerceError(error);
14
+ return statusByCode[normalized.code] ?? 500;
15
+ }
@@ -0,0 +1,89 @@
1
+ export interface CommerceError {
2
+ code: string;
3
+ message: string;
4
+ details?: unknown;
5
+ }
6
+
7
+ export interface FieldError {
8
+ field: string;
9
+ message: string;
10
+ }
11
+
12
+ export class CommerceNotFoundError extends Error implements CommerceError {
13
+ code = "NOT_FOUND" as const;
14
+ constructor(
15
+ message: string,
16
+ public details?: unknown,
17
+ ) {
18
+ super(message);
19
+ this.name = "CommerceNotFoundError";
20
+ }
21
+ }
22
+
23
+ export class CommerceValidationError extends Error implements CommerceError {
24
+ code = "VALIDATION_FAILED" as const;
25
+ constructor(
26
+ message: string,
27
+ public fieldErrors?: FieldError[],
28
+ public details?: unknown,
29
+ ) {
30
+ super(message);
31
+ this.name = "CommerceValidationError";
32
+ }
33
+ }
34
+
35
+ export class CommerceForbiddenError extends Error implements CommerceError {
36
+ code = "FORBIDDEN" as const;
37
+ constructor(
38
+ message: string,
39
+ public details?: unknown,
40
+ ) {
41
+ super(message);
42
+ this.name = "CommerceForbiddenError";
43
+ }
44
+ }
45
+
46
+ export class CommerceConflictError extends Error implements CommerceError {
47
+ code = "CONFLICT" as const;
48
+ constructor(
49
+ message: string,
50
+ public details?: unknown,
51
+ ) {
52
+ super(message);
53
+ this.name = "CommerceConflictError";
54
+ }
55
+ }
56
+
57
+ export class CommerceInvalidTransitionError extends Error implements CommerceError {
58
+ code = "INVALID_TRANSITION" as const;
59
+ constructor(
60
+ message: string,
61
+ public details?: unknown,
62
+ ) {
63
+ super(message);
64
+ this.name = "CommerceInvalidTransitionError";
65
+ }
66
+ }
67
+
68
+ export function isCommerceError(value: unknown): value is CommerceError {
69
+ if (!value || typeof value !== "object") return false;
70
+ return "code" in value && "message" in value;
71
+ }
72
+
73
+ export function toCommerceError(error: unknown): CommerceError {
74
+ if (isCommerceError(error)) {
75
+ return error;
76
+ }
77
+ if (error instanceof Error) {
78
+ return {
79
+ code: "INTERNAL_ERROR",
80
+ message: error.message,
81
+ details: { name: error.name },
82
+ };
83
+ }
84
+ return {
85
+ code: "INTERNAL_ERROR",
86
+ message: "Unexpected server error",
87
+ details: error,
88
+ };
89
+ }
@@ -0,0 +1,244 @@
1
+ import {
2
+ eq,
3
+ and,
4
+ isNull,
5
+ sql,
6
+ getTableColumns,
7
+ type SQL,
8
+ type Column,
9
+ type InferSelectModel,
10
+ type InferInsertModel,
11
+ } from "drizzle-orm";
12
+ import type { PgTableWithColumns, TableConfig } from "drizzle-orm/pg-core";
13
+ import { PgTable } from "drizzle-orm/pg-core";
14
+ import type { TxContext } from "../database/tx-context.js";
15
+ import type { DrizzleDatabase, DbOrTx } from "../database/drizzle-db.js";
16
+ import { CommerceNotFoundError } from "../errors.js";
17
+
18
+ /**
19
+ * Filter type — partial record of column values to match with eq().
20
+ * Only keys that exist on TRow and have non-undefined values are used.
21
+ */
22
+ export type Filters<TRow> = Partial<TRow>;
23
+
24
+ /**
25
+ * Options for findMany / findAndCount queries.
26
+ */
27
+ export interface FindOptions {
28
+ limit?: number;
29
+ offset?: number;
30
+ orderBy?: Array<{ column: string; direction: "asc" | "desc" }>;
31
+ withDeleted?: boolean;
32
+ }
33
+
34
+ /**
35
+ * Standard CRUD operations derived from a Drizzle pgTable schema.
36
+ * All methods support optional TxContext for transaction participation.
37
+ */
38
+ export interface BaseRepository<TRow, TInsert> {
39
+ findById(id: string, ctx?: TxContext): Promise<TRow | undefined>;
40
+ findMany(
41
+ filters?: Filters<TRow>,
42
+ options?: FindOptions,
43
+ ctx?: TxContext,
44
+ ): Promise<TRow[]>;
45
+ findAndCount(
46
+ filters?: Filters<TRow>,
47
+ options?: FindOptions,
48
+ ctx?: TxContext,
49
+ ): Promise<{ rows: TRow[]; total: number }>;
50
+ create(data: TInsert, ctx?: TxContext): Promise<TRow>;
51
+ createMany(data: TInsert[], ctx?: TxContext): Promise<TRow[]>;
52
+ update(id: string, data: Partial<TInsert>, ctx?: TxContext): Promise<TRow>;
53
+ delete(id: string, ctx?: TxContext): Promise<void>;
54
+ }
55
+
56
+ /**
57
+ * Extended repository for tables with a `deleted_at` column.
58
+ * Adds softDelete and restore operations.
59
+ */
60
+ export interface SoftDeletableRepository<TRow, TInsert>
61
+ extends BaseRepository<TRow, TInsert> {
62
+ softDelete(id: string, ctx?: TxContext): Promise<void>;
63
+ restore(id: string, ctx?: TxContext): Promise<TRow>;
64
+ }
65
+
66
+ /**
67
+ * Type-level check: does the table config have a `deletedAt` or `deleted_at` column?
68
+ */
69
+ type HasDeletedAt<T extends PgTableWithColumns<TableConfig>> =
70
+ "deletedAt" extends keyof T ? true : "deleted_at" extends keyof T ? true : false;
71
+
72
+ /**
73
+ * Conditional repository type: if the table has a deleted_at column,
74
+ * return SoftDeletableRepository; otherwise BaseRepository.
75
+ */
76
+ export type RepositoryFor<T extends PgTableWithColumns<TableConfig>> =
77
+ HasDeletedAt<T> extends true
78
+ ? SoftDeletableRepository<InferSelectModel<T>, InferInsertModel<T>>
79
+ : BaseRepository<InferSelectModel<T>, InferInsertModel<T>>;
80
+
81
+ /**
82
+ * Creates a typed repository with standard CRUD operations from a Drizzle table schema.
83
+ *
84
+ * Usage:
85
+ * ```typescript
86
+ * const repo = createRepository(schema.promotions, db)
87
+ * const row = await repo.findById("abc-123")
88
+ * const rows = await repo.findMany({ status: "active" }, { limit: 10 })
89
+ * ```
90
+ *
91
+ * Tables with a `deletedAt` column automatically get `softDelete()` and `restore()`.
92
+ * Domain-specific queries should remain in dedicated repository classes that
93
+ * delegate standard CRUD to the factory-created instance.
94
+ */
95
+ export function createRepository<T extends PgTableWithColumns<TableConfig>>(
96
+ table: T,
97
+ db: DrizzleDatabase,
98
+ ): RepositoryFor<T> {
99
+ // Use getTableColumns for type-safe column access
100
+ const columns = getTableColumns(table) as Record<string, Column>;
101
+
102
+ // Runtime check for soft-delete column
103
+ const hasSoftDelete = "deletedAt" in columns || "deleted_at" in columns;
104
+ const deletedAtColumn: Column | null =
105
+ columns["deletedAt"] ?? columns["deleted_at"] ?? null;
106
+ const idColumn = columns["id"]!;
107
+
108
+ // Drizzle's generic types require PgTable at the boundary.
109
+ // We cast once here rather than at every call site.
110
+ const pgTable = table as PgTable;
111
+
112
+ function getDb(ctx?: TxContext): DbOrTx {
113
+ return (ctx?.tx as DbOrTx | undefined) ?? db;
114
+ }
115
+
116
+ function buildWhereConditions(
117
+ filters?: Filters<InferSelectModel<T>>,
118
+ includeDeleted = false,
119
+ ): SQL[] {
120
+ const conditions: SQL[] = [];
121
+
122
+ // Automatically exclude soft-deleted rows unless explicitly requested
123
+ if (hasSoftDelete && !includeDeleted && deletedAtColumn) {
124
+ conditions.push(isNull(deletedAtColumn));
125
+ }
126
+
127
+ if (filters) {
128
+ for (const [key, value] of Object.entries(filters)) {
129
+ const col = columns[key];
130
+ if (value !== undefined && col) {
131
+ conditions.push(eq(col, value));
132
+ }
133
+ }
134
+ }
135
+
136
+ return conditions;
137
+ }
138
+
139
+ const repo: BaseRepository<InferSelectModel<T>, InferInsertModel<T>> = {
140
+ async findById(id, ctx) {
141
+ const conditions = buildWhereConditions(undefined, false);
142
+ conditions.push(eq(idColumn, id));
143
+ const rows = await getDb(ctx)
144
+ .select()
145
+ .from(pgTable)
146
+ .where(and(...conditions));
147
+ return rows[0] as InferSelectModel<T> | undefined;
148
+ },
149
+
150
+ async findMany(filters, options = {}, ctx) {
151
+ const conditions = buildWhereConditions(filters, options.withDeleted);
152
+ let query = getDb(ctx).select().from(pgTable).$dynamic();
153
+ if (conditions.length > 0) {
154
+ query = query.where(and(...conditions));
155
+ }
156
+ if (options.limit !== undefined) {
157
+ query = query.limit(options.limit);
158
+ }
159
+ if (options.offset !== undefined) {
160
+ query = query.offset(options.offset);
161
+ }
162
+ // Drizzle's $dynamic() erases the row type when using PgTable.
163
+ // Use .then() to narrow the resolved array type with a single cast.
164
+ return query.then((rows) => rows as InferSelectModel<T>[]);
165
+ },
166
+
167
+ async findAndCount(filters, options = {}, ctx) {
168
+ const rows = await repo.findMany(filters, options, ctx);
169
+ const conditions = buildWhereConditions(filters, options.withDeleted);
170
+ let countQuery = getDb(ctx)
171
+ .select({ count: sql<number>`count(*)::int` })
172
+ .from(pgTable)
173
+ .$dynamic();
174
+ if (conditions.length > 0) {
175
+ countQuery = countQuery.where(and(...conditions));
176
+ }
177
+ const countResult = await countQuery;
178
+ return { rows, total: countResult[0]?.count ?? 0 };
179
+ },
180
+
181
+ async create(data, ctx) {
182
+ const rows = await getDb(ctx)
183
+ .insert(pgTable)
184
+ .values(data as Record<string, unknown>)
185
+ .returning();
186
+ return rows[0] as InferSelectModel<T>;
187
+ },
188
+
189
+ async createMany(data, ctx) {
190
+ if (data.length === 0) return [];
191
+ const rows = await getDb(ctx)
192
+ .insert(pgTable)
193
+ .values(data as Record<string, unknown>[])
194
+ .returning();
195
+ return rows as InferSelectModel<T>[];
196
+ },
197
+
198
+ async update(id, data, ctx) {
199
+ const rows = await getDb(ctx)
200
+ .update(pgTable)
201
+ .set(data as Record<string, unknown>)
202
+ .where(eq(idColumn, id))
203
+ .returning();
204
+ if (!rows[0]) {
205
+ throw new CommerceNotFoundError(`Record ${id} not found.`);
206
+ }
207
+ return rows[0] as InferSelectModel<T>;
208
+ },
209
+
210
+ async delete(id, ctx) {
211
+ await getDb(ctx).delete(pgTable).where(eq(idColumn, id));
212
+ },
213
+ };
214
+
215
+ if (hasSoftDelete && deletedAtColumn) {
216
+ const softRepo = repo as SoftDeletableRepository<
217
+ InferSelectModel<T>,
218
+ InferInsertModel<T>
219
+ >;
220
+
221
+ softRepo.softDelete = async (id, ctx) => {
222
+ await getDb(ctx)
223
+ .update(pgTable)
224
+ .set({ deletedAt: new Date() } as Record<string, unknown>)
225
+ .where(eq(idColumn, id));
226
+ };
227
+
228
+ softRepo.restore = async (id, ctx) => {
229
+ const rows = await getDb(ctx)
230
+ .update(pgTable)
231
+ .set({ deletedAt: null } as Record<string, unknown>)
232
+ .where(eq(idColumn, id))
233
+ .returning();
234
+ if (!rows[0]) {
235
+ throw new CommerceNotFoundError(`Record ${id} not found.`);
236
+ }
237
+ return rows[0] as InferSelectModel<T>;
238
+ };
239
+
240
+ return softRepo as RepositoryFor<T>;
241
+ }
242
+
243
+ return repo as RepositoryFor<T>;
244
+ }
@@ -0,0 +1,43 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { Actor } from "../../auth/types.js";
3
+ import type { JobsAdapter } from "../jobs/adapter.js";
4
+ import { NullJobsAdapter } from "../jobs/adapter.js";
5
+ import type { PluginDb } from "../database/plugin-types.js";
6
+ import type { HookContext, HookOrigin, Logger, ServiceContainer } from "./types.js";
7
+
8
+ export interface CreateHookContextArgs {
9
+ actor: Actor | null;
10
+ tx?: unknown;
11
+ logger: Logger;
12
+ services: ServiceContainer;
13
+ context?: Record<string, unknown>;
14
+ requestId?: string;
15
+ origin?: HookOrigin;
16
+ jobs?: JobsAdapter;
17
+ db?: PluginDb;
18
+ kernel?: { database: { db: PluginDb } };
19
+ }
20
+
21
+ const nullJobs = new NullJobsAdapter();
22
+
23
+ /**
24
+ * Creates a HookContext with sensible defaults.
25
+ */
26
+ export function createHookContext(args: CreateHookContextArgs): HookContext {
27
+ // Resolve db: prefer explicit db arg, fall back to kernel.database.db
28
+ const db = args.db ?? args.kernel?.database?.db ?? null;
29
+
30
+ const ctx: HookContext = {
31
+ actor: args.actor,
32
+ tx: args.tx ?? null,
33
+ logger: args.logger,
34
+ services: args.services,
35
+ context: args.context ?? {},
36
+ requestId: args.requestId ?? randomUUID(),
37
+ origin: args.origin ?? "rest",
38
+ jobs: args.jobs ?? nullJobs,
39
+ db: db as PluginDb,
40
+ };
41
+
42
+ return ctx;
43
+ }
@@ -0,0 +1,88 @@
1
+ import type { AfterHook, BeforeHook, HookContext, HookOperation } from "./types.js";
2
+
3
+ export interface HookError {
4
+ hookName: string;
5
+ message: string;
6
+ }
7
+
8
+ export interface HookReport {
9
+ errors: HookError[];
10
+ hasErrors: boolean;
11
+ }
12
+
13
+ /** Default hook timeout: 20 seconds */
14
+ const HOOK_TIMEOUT_MS = 20_000;
15
+
16
+ function withTimeout<T>(promiseOrValue: Promise<T> | T, timeoutMs: number, hookName: string): Promise<T> {
17
+ const promise = Promise.resolve(promiseOrValue);
18
+ return new Promise<T>((resolve, reject) => {
19
+ const timer = setTimeout(
20
+ () => reject(new Error(`Hook "${hookName}" timed out after ${timeoutMs}ms`)),
21
+ timeoutMs,
22
+ );
23
+ promise.then(
24
+ (val) => { clearTimeout(timer); resolve(val); },
25
+ (err) => { clearTimeout(timer); reject(err); },
26
+ );
27
+ });
28
+ }
29
+
30
+ export async function runBeforeHooks<T>(
31
+ hooks: BeforeHook<T>[],
32
+ data: T,
33
+ operation: HookOperation,
34
+ context: HookContext,
35
+ ): Promise<T> {
36
+ let current = data;
37
+ for (const hook of hooks) {
38
+ const hookName = hook.name || "(anonymous beforeHook)";
39
+ try {
40
+ current = await withTimeout(
41
+ hook({ data: current, operation, context }),
42
+ HOOK_TIMEOUT_MS,
43
+ hookName,
44
+ );
45
+ } catch (error) {
46
+ context.logger.error(`Before-hook "${hookName}" failed during ${operation}`, {
47
+ error: error instanceof Error ? error.message : String(error),
48
+ requestId: context.requestId,
49
+ });
50
+ throw error; // Re-throw — beforeHooks MUST succeed
51
+ }
52
+ }
53
+ return current;
54
+ }
55
+
56
+ export async function runAfterHooks<T>(
57
+ hooks: AfterHook<T>[],
58
+ originalData: T | null,
59
+ committedResult: T,
60
+ operation: HookOperation,
61
+ context: HookContext,
62
+ ): Promise<HookReport> {
63
+ const errors: HookError[] = [];
64
+ for (const hook of hooks) {
65
+ const hookName = hook.name || "(anonymous afterHook)";
66
+ try {
67
+ await withTimeout(
68
+ hook({
69
+ data: originalData,
70
+ result: committedResult,
71
+ operation,
72
+ context,
73
+ }),
74
+ HOOK_TIMEOUT_MS,
75
+ hookName,
76
+ );
77
+ } catch (error) {
78
+ errors.push({
79
+ hookName,
80
+ message: error instanceof Error ? error.message : String(error),
81
+ });
82
+ context.logger.error(`After-hook "${hookName}" failed`, {
83
+ error,
84
+ });
85
+ }
86
+ }
87
+ return { errors, hasErrors: errors.length > 0 };
88
+ }