@nexpress/core 0.1.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.
Files changed (171) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +69 -0
  3. package/dist/audit-54XLVCWD.js +14 -0
  4. package/dist/audit-54XLVCWD.js.map +1 -0
  5. package/dist/auth.d.ts +640 -0
  6. package/dist/auth.js +94 -0
  7. package/dist/auth.js.map +1 -0
  8. package/dist/can-YLUHRJAB.js +19 -0
  9. package/dist/can-YLUHRJAB.js.map +1 -0
  10. package/dist/chunk-2G264RCD.js +68 -0
  11. package/dist/chunk-2G264RCD.js.map +1 -0
  12. package/dist/chunk-2YDGE7YX.js +92 -0
  13. package/dist/chunk-2YDGE7YX.js.map +1 -0
  14. package/dist/chunk-473S4TER.js +538 -0
  15. package/dist/chunk-473S4TER.js.map +1 -0
  16. package/dist/chunk-4ZLMEKFX.js +18 -0
  17. package/dist/chunk-4ZLMEKFX.js.map +1 -0
  18. package/dist/chunk-55FU6WED.js +179 -0
  19. package/dist/chunk-55FU6WED.js.map +1 -0
  20. package/dist/chunk-6YI5K2TI.js +1959 -0
  21. package/dist/chunk-6YI5K2TI.js.map +1 -0
  22. package/dist/chunk-BHK3AD3Q.js +41 -0
  23. package/dist/chunk-BHK3AD3Q.js.map +1 -0
  24. package/dist/chunk-CRUQBZUF.js +39 -0
  25. package/dist/chunk-CRUQBZUF.js.map +1 -0
  26. package/dist/chunk-CTSQ7BRI.js +175 -0
  27. package/dist/chunk-CTSQ7BRI.js.map +1 -0
  28. package/dist/chunk-DK2JBJH7.js +81 -0
  29. package/dist/chunk-DK2JBJH7.js.map +1 -0
  30. package/dist/chunk-DP2PREDU.js +597 -0
  31. package/dist/chunk-DP2PREDU.js.map +1 -0
  32. package/dist/chunk-EQ2Z3KMD.js +24 -0
  33. package/dist/chunk-EQ2Z3KMD.js.map +1 -0
  34. package/dist/chunk-FZ7O6DWI.js +305 -0
  35. package/dist/chunk-FZ7O6DWI.js.map +1 -0
  36. package/dist/chunk-ISLYFQWL.js +1270 -0
  37. package/dist/chunk-ISLYFQWL.js.map +1 -0
  38. package/dist/chunk-JJL74ZPK.js +68 -0
  39. package/dist/chunk-JJL74ZPK.js.map +1 -0
  40. package/dist/chunk-JKXAPSU4.js +24 -0
  41. package/dist/chunk-JKXAPSU4.js.map +1 -0
  42. package/dist/chunk-KU5M27ZC.js +24 -0
  43. package/dist/chunk-KU5M27ZC.js.map +1 -0
  44. package/dist/chunk-LSHHRDVR.js +34 -0
  45. package/dist/chunk-LSHHRDVR.js.map +1 -0
  46. package/dist/chunk-M43PGOQY.js +715 -0
  47. package/dist/chunk-M43PGOQY.js.map +1 -0
  48. package/dist/chunk-MEJAHXIO.js +150 -0
  49. package/dist/chunk-MEJAHXIO.js.map +1 -0
  50. package/dist/chunk-NUCGHWCF.js +101 -0
  51. package/dist/chunk-NUCGHWCF.js.map +1 -0
  52. package/dist/chunk-OK5HOCQI.js +845 -0
  53. package/dist/chunk-OK5HOCQI.js.map +1 -0
  54. package/dist/chunk-OROPGO65.js +13 -0
  55. package/dist/chunk-OROPGO65.js.map +1 -0
  56. package/dist/chunk-PPAS4SZR.js +176 -0
  57. package/dist/chunk-PPAS4SZR.js.map +1 -0
  58. package/dist/chunk-PPBWRKO2.js +171 -0
  59. package/dist/chunk-PPBWRKO2.js.map +1 -0
  60. package/dist/chunk-PZ5AY32C.js +10 -0
  61. package/dist/chunk-PZ5AY32C.js.map +1 -0
  62. package/dist/chunk-QO7LAQZH.js +321 -0
  63. package/dist/chunk-QO7LAQZH.js.map +1 -0
  64. package/dist/chunk-QVJ2HCAX.js +225 -0
  65. package/dist/chunk-QVJ2HCAX.js.map +1 -0
  66. package/dist/chunk-RIPHIRPP.js +68 -0
  67. package/dist/chunk-RIPHIRPP.js.map +1 -0
  68. package/dist/chunk-S27S42QY.js +134 -0
  69. package/dist/chunk-S27S42QY.js.map +1 -0
  70. package/dist/chunk-SBCVAC2Z.js +40 -0
  71. package/dist/chunk-SBCVAC2Z.js.map +1 -0
  72. package/dist/chunk-TFJ4MKPH.js +694 -0
  73. package/dist/chunk-TFJ4MKPH.js.map +1 -0
  74. package/dist/chunk-THX3SHYA.js +75 -0
  75. package/dist/chunk-THX3SHYA.js.map +1 -0
  76. package/dist/chunk-UGQSQO5B.js +222 -0
  77. package/dist/chunk-UGQSQO5B.js.map +1 -0
  78. package/dist/chunk-V2UNHGAP.js +26 -0
  79. package/dist/chunk-V2UNHGAP.js.map +1 -0
  80. package/dist/chunk-VGTPQXNQ.js +2790 -0
  81. package/dist/chunk-VGTPQXNQ.js.map +1 -0
  82. package/dist/chunk-VNIHXQ7W.js +194 -0
  83. package/dist/chunk-VNIHXQ7W.js.map +1 -0
  84. package/dist/chunk-WV272MPW.js +31 -0
  85. package/dist/chunk-WV272MPW.js.map +1 -0
  86. package/dist/chunk-X5KKBOUS.js +26 -0
  87. package/dist/chunk-X5KKBOUS.js.map +1 -0
  88. package/dist/chunk-XANPEOJC.js +17 -0
  89. package/dist/chunk-XANPEOJC.js.map +1 -0
  90. package/dist/chunk-XPVQIHAQ.js +83 -0
  91. package/dist/chunk-XPVQIHAQ.js.map +1 -0
  92. package/dist/chunk-ZCINJSS4.js +75 -0
  93. package/dist/chunk-ZCINJSS4.js.map +1 -0
  94. package/dist/community.d.ts +1425 -0
  95. package/dist/community.js +206 -0
  96. package/dist/community.js.map +1 -0
  97. package/dist/config-2GDU7PCK.js +32 -0
  98. package/dist/config-2GDU7PCK.js.map +1 -0
  99. package/dist/context-MNZ4QXPC.js +16 -0
  100. package/dist/context-MNZ4QXPC.js.map +1 -0
  101. package/dist/db-schema.d.ts +4 -0
  102. package/dist/db-schema.js +102 -0
  103. package/dist/db-schema.js.map +1 -0
  104. package/dist/db.d.ts +7 -0
  105. package/dist/db.js +117 -0
  106. package/dist/db.js.map +1 -0
  107. package/dist/digest-SY42GQSU.js +17 -0
  108. package/dist/digest-SY42GQSU.js.map +1 -0
  109. package/dist/errors-5OS3S2J3.js +22 -0
  110. package/dist/errors-5OS3S2J3.js.map +1 -0
  111. package/dist/host-OBOI4MJK.js +51 -0
  112. package/dist/host-OBOI4MJK.js.map +1 -0
  113. package/dist/i18n.d.ts +301 -0
  114. package/dist/i18n.js +68 -0
  115. package/dist/i18n.js.map +1 -0
  116. package/dist/index-B6-_vr_m.d.ts +590 -0
  117. package/dist/index-CY55LC0u.d.ts +4722 -0
  118. package/dist/index-CeiTvwbp.d.ts +168 -0
  119. package/dist/index-XwP1ET8b.d.ts +61 -0
  120. package/dist/index.d.ts +2037 -0
  121. package/dist/index.js +2205 -0
  122. package/dist/index.js.map +1 -0
  123. package/dist/job-log-VZXWQUDK.js +24 -0
  124. package/dist/job-log-VZXWQUDK.js.map +1 -0
  125. package/dist/jobs.d.ts +4 -0
  126. package/dist/jobs.js +76 -0
  127. package/dist/jobs.js.map +1 -0
  128. package/dist/logger-DqGaOU_j.d.ts +29 -0
  129. package/dist/logger-S7REWDNE.js +16 -0
  130. package/dist/logger-S7REWDNE.js.map +1 -0
  131. package/dist/media.d.ts +5 -0
  132. package/dist/media.js +41 -0
  133. package/dist/media.js.map +1 -0
  134. package/dist/mentions-2IHFVSHW.js +23 -0
  135. package/dist/mentions-2IHFVSHW.js.map +1 -0
  136. package/dist/mutes-EWAE5FZR.js +21 -0
  137. package/dist/mutes-EWAE5FZR.js.map +1 -0
  138. package/dist/notification-prefs-VPJDU7I6.js +21 -0
  139. package/dist/notification-prefs-VPJDU7I6.js.map +1 -0
  140. package/dist/observability.d.ts +156 -0
  141. package/dist/observability.js +32 -0
  142. package/dist/observability.js.map +1 -0
  143. package/dist/profanity-adapter-NU2JQSLX.js +12 -0
  144. package/dist/profanity-adapter-NU2JQSLX.js.map +1 -0
  145. package/dist/queue-XE5BC75T.js +14 -0
  146. package/dist/queue-XE5BC75T.js.map +1 -0
  147. package/dist/rate-limit.d.ts +99 -0
  148. package/dist/rate-limit.js +14 -0
  149. package/dist/rate-limit.js.map +1 -0
  150. package/dist/registry-XIXDEPVI.js +31 -0
  151. package/dist/registry-XIXDEPVI.js.map +1 -0
  152. package/dist/reputation-JRL2YQHM.js +11 -0
  153. package/dist/reputation-JRL2YQHM.js.map +1 -0
  154. package/dist/routes.d.ts +43 -0
  155. package/dist/routes.js +12 -0
  156. package/dist/routes.js.map +1 -0
  157. package/dist/scheduled-CIQM57HT.js +20 -0
  158. package/dist/scheduled-CIQM57HT.js.map +1 -0
  159. package/dist/seo.d.ts +410 -0
  160. package/dist/seo.js +44 -0
  161. package/dist/seo.js.map +1 -0
  162. package/dist/settings-FOBIESPB.js +17 -0
  163. package/dist/settings-FOBIESPB.js.map +1 -0
  164. package/dist/spam-adapter-XX3G737Z.js +12 -0
  165. package/dist/spam-adapter-XX3G737Z.js.map +1 -0
  166. package/dist/strings-VAE47B2C.js +29 -0
  167. package/dist/strings-VAE47B2C.js.map +1 -0
  168. package/dist/templates-IFVJMCJ6.js +12 -0
  169. package/dist/templates-IFVJMCJ6.js.map +1 -0
  170. package/dist/types-TlsbXS0T.d.ts +871 -0
  171. package/package.json +129 -0
@@ -0,0 +1,194 @@
1
+ import {
2
+ recordDigestSent
3
+ } from "./chunk-CTSQ7BRI.js";
4
+ import {
5
+ NP_DEFAULT_SITE_ID,
6
+ listSites
7
+ } from "./chunk-FZ7O6DWI.js";
8
+ import {
9
+ getEmailAdapter
10
+ } from "./chunk-LSHHRDVR.js";
11
+ import {
12
+ getLogger
13
+ } from "./chunk-JJL74ZPK.js";
14
+ import {
15
+ getDb
16
+ } from "./chunk-XANPEOJC.js";
17
+ import {
18
+ npMembers,
19
+ npNotifications
20
+ } from "./chunk-M43PGOQY.js";
21
+
22
+ // src/community/digest.ts
23
+ import { and, desc, eq, gt, isNull, sql } from "drizzle-orm";
24
+ var LABELS = {
25
+ "comment.reply": "New reply on your comment",
26
+ "comment.mention": "You were mentioned in a comment",
27
+ "document.mention": "You were mentioned in a discussion",
28
+ "reaction.received": "Someone reacted to your content",
29
+ "follow.received": "Someone followed you"
30
+ };
31
+ function labelFor(kind) {
32
+ return LABELS[kind] ?? `Notification (${kind})`;
33
+ }
34
+ function escapeHtml(value) {
35
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
36
+ }
37
+ function buildDigestEmail(input) {
38
+ const site = input.siteName ?? "your site";
39
+ const cadenceWord = input.cadence === "weekly" ? "weekly" : "daily";
40
+ const total = input.notifications.length;
41
+ const subject = total === 1 ? `Your ${cadenceWord} digest from ${site}: 1 notification` : `Your ${cadenceWord} digest from ${site}: ${total} notifications`;
42
+ const lines = input.notifications.map((n) => {
43
+ const label = labelFor(n.kind);
44
+ const when = n.createdAt.toISOString();
45
+ return `- ${label} (${when})`;
46
+ });
47
+ const text = [
48
+ `Hi @${input.member.handle},`,
49
+ "",
50
+ `You have ${total} unread notification${total === 1 ? "" : "s"} from the last ${cadenceWord} window:`,
51
+ "",
52
+ ...lines,
53
+ "",
54
+ `Manage your digest settings: /members/me/notifications`
55
+ ].join("\n");
56
+ const items = input.notifications.map((n) => {
57
+ const label = escapeHtml(labelFor(n.kind));
58
+ const when = escapeHtml(n.createdAt.toISOString());
59
+ return `<li><strong>${label}</strong> <span style="color:#64748b">\u2014 ${when}</span></li>`;
60
+ }).join("");
61
+ const html = [
62
+ `<p>Hi @${escapeHtml(input.member.handle)},</p>`,
63
+ `<p>You have ${total} unread notification${total === 1 ? "" : "s"} from the last ${cadenceWord} window:</p>`,
64
+ `<ul>${items}</ul>`,
65
+ `<p style="color:#64748b;font-size:0.9rem">`,
66
+ `Manage your digest settings at `,
67
+ `<a href="/members/me/notifications">/members/me/notifications</a>.`,
68
+ `</p>`
69
+ ].join("");
70
+ return { subject, text, html };
71
+ }
72
+ function fallbackWindow(cadence, now) {
73
+ const ms = cadence === "weekly" ? 7 * 24 * 60 * 60 * 1e3 : 24 * 60 * 60 * 1e3;
74
+ return new Date(now.getTime() - ms);
75
+ }
76
+ async function listMembersForCadence(db, cadence) {
77
+ const rows = await db.select({
78
+ id: npMembers.id,
79
+ email: npMembers.email,
80
+ handle: npMembers.handle,
81
+ displayName: npMembers.displayName,
82
+ prefs: npMembers.notificationPrefs,
83
+ status: npMembers.status
84
+ }).from(npMembers).where(
85
+ and(
86
+ eq(npMembers.status, "active"),
87
+ sql`${npMembers.notificationPrefs} ->> 'digest' = ${cadence}`
88
+ )
89
+ );
90
+ return rows.map((r) => ({
91
+ id: r.id,
92
+ email: r.email,
93
+ handle: r.handle,
94
+ displayName: r.displayName,
95
+ prefs: r.prefs
96
+ }));
97
+ }
98
+ async function fetchUnreadSince(db, memberId, siteId, since) {
99
+ const rows = await db.select({
100
+ id: npNotifications.id,
101
+ kind: npNotifications.kind,
102
+ payload: npNotifications.payload,
103
+ createdAt: npNotifications.createdAt
104
+ }).from(npNotifications).where(
105
+ and(
106
+ eq(npNotifications.memberId, memberId),
107
+ // Issue #218 — scope to the site we're sweeping. Without
108
+ // this the digest mixed inboxes across tenants and the
109
+ // recipient saw notifications from sites they don't even
110
+ // know exist.
111
+ eq(npNotifications.siteId, siteId),
112
+ // Unread + within the window. If the member already read
113
+ // everything in the inbox the digest would be noise, so we
114
+ // skip silently (caller increments `skipped` when the list
115
+ // comes back empty).
116
+ gt(npNotifications.createdAt, since),
117
+ isNull(npNotifications.readAt)
118
+ )
119
+ ).orderBy(desc(npNotifications.createdAt)).limit(50);
120
+ return rows;
121
+ }
122
+ async function runDigestSweep(input) {
123
+ const now = input.now ?? /* @__PURE__ */ new Date();
124
+ const db = getDb();
125
+ const adapter = getEmailAdapter();
126
+ const log = getLogger();
127
+ const sites = await listSites();
128
+ const candidateSites = sites.length > 0 ? sites : [{ id: NP_DEFAULT_SITE_ID, name: "" }];
129
+ const members = await listMembersForCadence(db, input.cadence);
130
+ let considered = 0;
131
+ let sent = 0;
132
+ let skipped = 0;
133
+ let failed = 0;
134
+ for (const site of candidateSites) {
135
+ for (const member of members) {
136
+ considered += 1;
137
+ const since = lastDigestSinceFor(member, site.id, input.cadence, now);
138
+ const notifications = await fetchUnreadSince(db, member.id, site.id, since);
139
+ if (notifications.length === 0) {
140
+ skipped += 1;
141
+ continue;
142
+ }
143
+ const email = buildDigestEmail({
144
+ member: { displayName: member.displayName, handle: member.handle },
145
+ notifications,
146
+ cadence: input.cadence,
147
+ // Caller-supplied `siteName` is an explicit override
148
+ // (single-tenant deploys, tests pinning a friendly
149
+ // brand name); the per-site `name` is the natural
150
+ // multi-tenant default.
151
+ siteName: input.siteName && input.siteName.length > 0 ? input.siteName : typeof site.name === "string" && site.name.length > 0 ? site.name : void 0
152
+ });
153
+ try {
154
+ await adapter.send({
155
+ to: member.email,
156
+ subject: email.subject,
157
+ text: email.text,
158
+ html: email.html
159
+ });
160
+ await recordDigestSent(member.id, now, { siteId: site.id, cadence: input.cadence });
161
+ sent += 1;
162
+ } catch (err) {
163
+ failed += 1;
164
+ log.warn("digest send failed", {
165
+ memberId: member.id,
166
+ siteId: site.id,
167
+ cadence: input.cadence,
168
+ error: err instanceof Error ? err.message : String(err)
169
+ });
170
+ }
171
+ }
172
+ }
173
+ return { considered, sent, skipped, failed };
174
+ }
175
+ function lastDigestSinceFor(member, siteId, cadence, now) {
176
+ const prefs = member.prefs ?? {};
177
+ const bySite = prefs.lastDigestAtBySite;
178
+ const perSite = bySite?.[siteId]?.[cadence];
179
+ if (typeof perSite === "string") {
180
+ const parsed = new Date(perSite);
181
+ if (Number.isFinite(parsed.getTime())) return parsed;
182
+ }
183
+ if (typeof prefs.lastDigestAt === "string") {
184
+ const parsed = new Date(prefs.lastDigestAt);
185
+ if (Number.isFinite(parsed.getTime())) return parsed;
186
+ }
187
+ return fallbackWindow(cadence, now);
188
+ }
189
+
190
+ export {
191
+ buildDigestEmail,
192
+ runDigestSweep
193
+ };
194
+ //# sourceMappingURL=chunk-VNIHXQ7W.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/community/digest.ts"],"sourcesContent":["import { and, desc, eq, gt, isNull, sql } from \"drizzle-orm\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\n\nimport { getDb } from \"../db/runtime.js\";\nimport { npMembers, npNotifications } from \"../db/schema/community.js\";\nimport { getEmailAdapter } from \"../email/service.js\";\nimport { getLogger } from \"../observability/logger.js\";\nimport { listSites, NP_DEFAULT_SITE_ID } from \"../sites/registry.js\";\n\nimport { type NpDigestCadence, recordDigestSent } from \"./notification-prefs.js\";\n\n/**\n * Phase 16.4 — email digest fan-out. The `notifications:sendDigest`\n * recurring job calls `runDigestSweep(cadence)` on a daily and a\n * weekly schedule; the function fetches every active member who\n * opted into that cadence, builds an inbox summary scoped to \"since\n * last digest\" (falling back to the cadence window when the member\n * has never received one), renders an email through the configured\n * `NpEmailAdapter`, and stamps `lastDigestAt` on success.\n *\n * The job is idempotent enough for production use: a sweep that\n * runs twice for the same window won't re-email members because\n * `lastDigestAt` advances on the first send. Failures inside the\n * loop are logged-and-continued — one stuck member doesn't block\n * the rest of the sweep.\n */\n\nexport interface NpDigestNotificationSummary {\n id: string;\n kind: string;\n payload: Record<string, unknown>;\n createdAt: Date;\n}\n\nexport interface NpDigestEmailContent {\n subject: string;\n text: string;\n html: string;\n}\n\nexport interface BuildDigestEmailInput {\n member: { displayName: string; handle: string };\n notifications: NpDigestNotificationSummary[];\n cadence: NpDigestCadence;\n /** Site display name; defaults to \"your site\" so the noop adapter is still readable. */\n siteName?: string;\n}\n\nconst LABELS: Record<string, string> = {\n \"comment.reply\": \"New reply on your comment\",\n \"comment.mention\": \"You were mentioned in a comment\",\n \"document.mention\": \"You were mentioned in a discussion\",\n \"reaction.received\": \"Someone reacted to your content\",\n \"follow.received\": \"Someone followed you\",\n};\n\nfunction labelFor(kind: string): string {\n return LABELS[kind] ?? `Notification (${kind})`;\n}\n\nfunction escapeHtml(value: string): string {\n return value\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\");\n}\n\n/**\n * Pure renderer; exposed so plugins / tests can call it without\n * the DB read path.\n */\nexport function buildDigestEmail(input: BuildDigestEmailInput): NpDigestEmailContent {\n const site = input.siteName ?? \"your site\";\n const cadenceWord = input.cadence === \"weekly\" ? \"weekly\" : \"daily\";\n const total = input.notifications.length;\n const subject =\n total === 1\n ? `Your ${cadenceWord} digest from ${site}: 1 notification`\n : `Your ${cadenceWord} digest from ${site}: ${total} notifications`;\n\n const lines = input.notifications.map((n) => {\n const label = labelFor(n.kind);\n const when = n.createdAt.toISOString();\n return `- ${label} (${when})`;\n });\n const text = [\n `Hi @${input.member.handle},`,\n \"\",\n `You have ${total} unread notification${total === 1 ? \"\" : \"s\"} from the last ${cadenceWord} window:`,\n \"\",\n ...lines,\n \"\",\n `Manage your digest settings: /members/me/notifications`,\n ].join(\"\\n\");\n\n const items = input.notifications\n .map((n) => {\n const label = escapeHtml(labelFor(n.kind));\n const when = escapeHtml(n.createdAt.toISOString());\n return `<li><strong>${label}</strong> <span style=\"color:#64748b\">— ${when}</span></li>`;\n })\n .join(\"\");\n const html = [\n `<p>Hi @${escapeHtml(input.member.handle)},</p>`,\n `<p>You have ${total} unread notification${total === 1 ? \"\" : \"s\"} from the last ${cadenceWord} window:</p>`,\n `<ul>${items}</ul>`,\n `<p style=\"color:#64748b;font-size:0.9rem\">`,\n `Manage your digest settings at `,\n `<a href=\"/members/me/notifications\">/members/me/notifications</a>.`,\n `</p>`,\n ].join(\"\");\n\n return { subject, text, html };\n}\n\ninterface MemberDigestRow {\n id: string;\n email: string;\n handle: string;\n displayName: string;\n prefs: Record<string, unknown>;\n}\n\nfunction fallbackWindow(cadence: NpDigestCadence, now: Date): Date {\n const ms = cadence === \"weekly\" ? 7 * 24 * 60 * 60 * 1000 : 24 * 60 * 60 * 1000;\n return new Date(now.getTime() - ms);\n}\n\n/**\n * Pulls every active member whose `notification_prefs.digest`\n * matches `cadence`. The JSONB filter uses Postgres `->>`\n * extraction; the `digest` field is a small string, indexes are\n * unnecessary at v1 scale.\n */\nasync function listMembersForCadence(\n db: NodePgDatabase<Record<string, unknown>>,\n cadence: Exclude<NpDigestCadence, \"off\">,\n): Promise<MemberDigestRow[]> {\n const rows = (await db\n .select({\n id: npMembers.id,\n email: npMembers.email,\n handle: npMembers.handle,\n displayName: npMembers.displayName,\n prefs: npMembers.notificationPrefs,\n status: npMembers.status,\n })\n .from(npMembers)\n .where(\n and(\n eq(npMembers.status, \"active\"),\n sql`${npMembers.notificationPrefs} ->> 'digest' = ${cadence}`,\n ),\n )) as Array<MemberDigestRow & { status: string }>;\n return rows.map((r) => ({\n id: r.id,\n email: r.email,\n handle: r.handle,\n displayName: r.displayName,\n prefs: r.prefs,\n }));\n}\n\nasync function fetchUnreadSince(\n db: NodePgDatabase<Record<string, unknown>>,\n memberId: string,\n siteId: string,\n since: Date,\n): Promise<NpDigestNotificationSummary[]> {\n const rows = (await db\n .select({\n id: npNotifications.id,\n kind: npNotifications.kind,\n payload: npNotifications.payload,\n createdAt: npNotifications.createdAt,\n })\n .from(npNotifications)\n .where(\n and(\n eq(npNotifications.memberId, memberId),\n // Issue #218 — scope to the site we're sweeping. Without\n // this the digest mixed inboxes across tenants and the\n // recipient saw notifications from sites they don't even\n // know exist.\n eq(npNotifications.siteId, siteId),\n // Unread + within the window. If the member already read\n // everything in the inbox the digest would be noise, so we\n // skip silently (caller increments `skipped` when the list\n // comes back empty).\n gt(npNotifications.createdAt, since),\n isNull(npNotifications.readAt),\n ),\n )\n .orderBy(desc(npNotifications.createdAt))\n .limit(50)) as NpDigestNotificationSummary[];\n return rows;\n}\n\nexport interface RunDigestSweepInput {\n cadence: \"daily\" | \"weekly\";\n /** Defaults to `new Date()`. Tests override for determinism. */\n now?: Date;\n /** Site name woven into subject + body. Defaults to `\"your site\"`. */\n siteName?: string;\n}\n\nexport interface RunDigestSweepResult {\n considered: number;\n sent: number;\n skipped: number;\n failed: number;\n}\n\nexport async function runDigestSweep(input: RunDigestSweepInput): Promise<RunDigestSweepResult> {\n const now = input.now ?? new Date();\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n const adapter = getEmailAdapter();\n const log = getLogger();\n\n // Issue #218 — fan-out per site. The previous implementation\n // ran a single sweep that mixed every tenant's inbox into one\n // digest and stamped one global `lastDigestAt`; advancing it\n // for tenant A would suppress tenant B's next digest entirely.\n // We now iterate the site registry and run an independent\n // sweep per (site, member) — same email cadence, but each\n // recipient gets one email per site they have unread\n // notifications on.\n const sites = await listSites();\n const candidateSites = sites.length > 0 ? sites : [{ id: NP_DEFAULT_SITE_ID, name: \"\" }];\n const members = await listMembersForCadence(db, input.cadence);\n\n let considered = 0;\n let sent = 0;\n let skipped = 0;\n let failed = 0;\n\n for (const site of candidateSites) {\n for (const member of members) {\n considered += 1;\n const since = lastDigestSinceFor(member, site.id, input.cadence, now);\n\n const notifications = await fetchUnreadSince(db, member.id, site.id, since);\n if (notifications.length === 0) {\n skipped += 1;\n continue;\n }\n\n const email = buildDigestEmail({\n member: { displayName: member.displayName, handle: member.handle },\n notifications,\n cadence: input.cadence,\n // Caller-supplied `siteName` is an explicit override\n // (single-tenant deploys, tests pinning a friendly\n // brand name); the per-site `name` is the natural\n // multi-tenant default.\n siteName:\n input.siteName && input.siteName.length > 0\n ? input.siteName\n : typeof site.name === \"string\" && site.name.length > 0\n ? site.name\n : undefined,\n });\n\n try {\n await adapter.send({\n to: member.email,\n subject: email.subject,\n text: email.text,\n html: email.html,\n });\n await recordDigestSent(member.id, now, { siteId: site.id, cadence: input.cadence });\n sent += 1;\n } catch (err) {\n failed += 1;\n log.warn(\"digest send failed\", {\n memberId: member.id,\n siteId: site.id,\n cadence: input.cadence,\n error: err instanceof Error ? err.message : String(err),\n });\n }\n }\n }\n\n return { considered, sent, skipped, failed };\n}\n\n/**\n * Issue #218 — pick the right \"since\" cutoff for one (site,\n * member, cadence) sweep. Reads precedence:\n * 1. `lastDigestAtBySite[siteId][cadence]` — the per-site\n * timestamp the new sweep writes after each successful send.\n * 2. legacy `lastDigestAt` — single-tenant deploys without\n * site-scoped writes still keep their existing window.\n * 3. fallback window (24h / 7d) — a member who has never\n * received any digest.\n */\nfunction lastDigestSinceFor(\n member: MemberDigestRow,\n siteId: string,\n cadence: NpDigestCadence,\n now: Date,\n): Date {\n const prefs = (member.prefs ?? {});\n const bySite = prefs.lastDigestAtBySite as\n | Record<string, Partial<Record<string, string>>>\n | undefined;\n const perSite = bySite?.[siteId]?.[cadence];\n if (typeof perSite === \"string\") {\n const parsed = new Date(perSite);\n if (Number.isFinite(parsed.getTime())) return parsed;\n }\n if (typeof prefs.lastDigestAt === \"string\") {\n const parsed = new Date(prefs.lastDigestAt);\n if (Number.isFinite(parsed.getTime())) return parsed;\n }\n return fallbackWindow(cadence, now);\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,31 @@
1
+ // src/observability/error-reporter.ts
2
+ var noopErrorReporter = {
3
+ captureException: () => {
4
+ }
5
+ };
6
+ var currentReporter = noopErrorReporter;
7
+ function setErrorReporter(reporter) {
8
+ currentReporter = reporter;
9
+ }
10
+ function getErrorReporter() {
11
+ return currentReporter;
12
+ }
13
+ async function reportError(error, context) {
14
+ try {
15
+ await currentReporter.captureException(error, context);
16
+ } catch (reporterError) {
17
+ console.error("[nexpress] error reporter itself threw:", reporterError);
18
+ }
19
+ }
20
+ function resetErrorReporter() {
21
+ currentReporter = noopErrorReporter;
22
+ }
23
+
24
+ export {
25
+ noopErrorReporter,
26
+ setErrorReporter,
27
+ getErrorReporter,
28
+ reportError,
29
+ resetErrorReporter
30
+ };
31
+ //# sourceMappingURL=chunk-WV272MPW.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/observability/error-reporter.ts"],"sourcesContent":["/**\n * Pluggable error reporter. The default is a no-op so users who don't\n * need third-party tracking pay nothing; production deployments can\n * install a Sentry / Bugsnag / Honeybadger / Rollbar adapter via\n * `setErrorReporter()`.\n *\n * The framework reports errors at three boundaries:\n * 1. Unhandled exceptions in API route handlers (via `npErrorResponse`).\n * 2. Plugin hook handlers that throw (via the plugin host).\n * 3. pg-boss job handlers that throw (registered by the worker process).\n *\n * Reporters MUST NOT throw — exceptions inside `captureException` are\n * caught and logged, never propagated.\n */\nexport interface NpErrorReportContext {\n /**\n * Free-form tags used by error trackers for filtering and grouping.\n * E.g. `{ source: \"api\", route: \"/api/collections/posts\" }`.\n */\n tags?: Record<string, string>;\n /** Optional user identity, populated when the error happened in a\n * request context. */\n user?: { id?: string; email?: string; role?: string };\n /** Arbitrary extra context — request body shape, plugin id, job name. */\n extra?: Record<string, unknown>;\n}\n\nexport interface NpErrorReporter {\n captureException(error: Error, context?: NpErrorReportContext): void | Promise<void>;\n}\n\n/** Default — does nothing. Replaceable via `setErrorReporter`. */\nexport const noopErrorReporter: NpErrorReporter = {\n captureException: () => {\n /* no-op */\n },\n};\n\nlet currentReporter: NpErrorReporter = noopErrorReporter;\n\n/** Replace the global error reporter. Call once at app boot. */\nexport function setErrorReporter(reporter: NpErrorReporter): void {\n currentReporter = reporter;\n}\n\n/** Returns the currently-installed reporter. Defaults to no-op. */\nexport function getErrorReporter(): NpErrorReporter {\n return currentReporter;\n}\n\n/**\n * Safe wrapper that swallows reporter exceptions so error reporting can\n * never itself crash the host. Logs the underlying capture failure via\n * `console.error` — using `getLogger()` here would risk a loop if the\n * logger is also broken.\n */\nexport async function reportError(error: Error, context?: NpErrorReportContext): Promise<void> {\n try {\n await currentReporter.captureException(error, context);\n } catch (reporterError) {\n // Last-resort: bypass the logger to avoid a circular failure path.\n \n console.error(\"[nexpress] error reporter itself threw:\", reporterError);\n }\n}\n\n/** Reset to the default no-op reporter. Tests use this to undo `setErrorReporter`. */\nexport function resetErrorReporter(): void {\n currentReporter = noopErrorReporter;\n}\n"],"mappings":";AAgCO,IAAM,oBAAqC;AAAA,EAChD,kBAAkB,MAAM;AAAA,EAExB;AACF;AAEA,IAAI,kBAAmC;AAGhC,SAAS,iBAAiB,UAAiC;AAChE,oBAAkB;AACpB;AAGO,SAAS,mBAAoC;AAClD,SAAO;AACT;AAQA,eAAsB,YAAY,OAAc,SAA+C;AAC7F,MAAI;AACF,UAAM,gBAAgB,iBAAiB,OAAO,OAAO;AAAA,EACvD,SAAS,eAAe;AAGtB,YAAQ,MAAM,2CAA2C,aAAa;AAAA,EACxE;AACF;AAGO,SAAS,qBAA2B;AACzC,oBAAkB;AACpB;","names":[]}
@@ -0,0 +1,26 @@
1
+ // src/routes/registry.ts
2
+ var registry = /* @__PURE__ */ new Map();
3
+ function registerCustomRoute(route) {
4
+ if (typeof route.path !== "string" || !route.path.startsWith("/")) {
5
+ throw new TypeError(
6
+ `registerCustomRoute: 'path' must start with '/', got ${JSON.stringify(route.path)}`
7
+ );
8
+ }
9
+ if (typeof route.label !== "string" || route.label.trim().length === 0) {
10
+ throw new TypeError("registerCustomRoute: 'label' must be a non-empty string");
11
+ }
12
+ registry.set(route.path, { ...route });
13
+ }
14
+ function getCustomRoutes() {
15
+ return Array.from(registry.values());
16
+ }
17
+ function clearCustomRoutes() {
18
+ registry.clear();
19
+ }
20
+
21
+ export {
22
+ registerCustomRoute,
23
+ getCustomRoutes,
24
+ clearCustomRoutes
25
+ };
26
+ //# sourceMappingURL=chunk-X5KKBOUS.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/routes/registry.ts"],"sourcesContent":["/**\n * Developer-declared custom-route registry. Hand-coded Next.js routes\n * under `apps/web/src/app/(site)/*` are invisible to the framework\n * (the catch-all `[[...slug]]` only knows about CMS pages, plugins\n * declare their own routes via `definePlugin({ routes })`). Operators\n * still need to discover and link to those hand-coded surfaces from\n * the admin — the navigation editor in particular.\n *\n * App code declares each navigable hand-coded route with\n * `registerCustomRoute(...)` at boot. The admin reads the registry\n * via `getCustomRoutes()` to populate a Settings tab and the nav\n * editor's URL autocomplete.\n *\n * Re-registering the same `path` overwrites silently — same HMR-safe\n * convention as the block registry. The registry is process-scoped;\n * sites in a multi-tenant deployment share the same set because all\n * sites share the same code bundle.\n */\nexport interface NpCustomRoute {\n /**\n * The route's URL path. Must start with `/`. May include dynamic\n * segments for documentation purposes (e.g. `/u/[username]`), but\n * such routes won't appear as nav-link targets — the autocomplete\n * filters them out because a literal href can't be derived.\n */\n path: string;\n /** Short human label for the admin UI. */\n label: string;\n /** Optional one-line description. */\n description?: string;\n /**\n * Optional Lucide icon name (lowercase kebab-case, matching the\n * shared `BlockIcon` resolver). Defaults to `route` if unset.\n */\n icon?: string;\n /** Optional grouping key for the admin list (e.g. \"content\", \"community\"). */\n group?: string;\n}\n\nconst registry = new Map<string, NpCustomRoute>();\n\nexport function registerCustomRoute(route: NpCustomRoute): void {\n if (typeof route.path !== \"string\" || !route.path.startsWith(\"/\")) {\n throw new TypeError(\n `registerCustomRoute: 'path' must start with '/', got ${JSON.stringify(route.path)}`,\n );\n }\n if (typeof route.label !== \"string\" || route.label.trim().length === 0) {\n throw new TypeError(\"registerCustomRoute: 'label' must be a non-empty string\");\n }\n registry.set(route.path, { ...route });\n}\n\nexport function getCustomRoutes(): NpCustomRoute[] {\n return Array.from(registry.values());\n}\n\nexport function clearCustomRoutes(): void {\n registry.clear();\n}\n"],"mappings":";AAuCA,IAAM,WAAW,oBAAI,IAA2B;AAEzC,SAAS,oBAAoB,OAA4B;AAC9D,MAAI,OAAO,MAAM,SAAS,YAAY,CAAC,MAAM,KAAK,WAAW,GAAG,GAAG;AACjE,UAAM,IAAI;AAAA,MACR,wDAAwD,KAAK,UAAU,MAAM,IAAI,CAAC;AAAA,IACpF;AAAA,EACF;AACA,MAAI,OAAO,MAAM,UAAU,YAAY,MAAM,MAAM,KAAK,EAAE,WAAW,GAAG;AACtE,UAAM,IAAI,UAAU,yDAAyD;AAAA,EAC/E;AACA,WAAS,IAAI,MAAM,MAAM,EAAE,GAAG,MAAM,CAAC;AACvC;AAEO,SAAS,kBAAmC;AACjD,SAAO,MAAM,KAAK,SAAS,OAAO,CAAC;AACrC;AAEO,SAAS,oBAA0B;AACxC,WAAS,MAAM;AACjB;","names":[]}
@@ -0,0 +1,17 @@
1
+ // src/db/runtime.ts
2
+ var dbInstance = null;
3
+ function setDb(db) {
4
+ dbInstance = db;
5
+ }
6
+ function getDb() {
7
+ if (!dbInstance) {
8
+ throw new Error("Database not initialized. Call setDb() first.");
9
+ }
10
+ return dbInstance;
11
+ }
12
+
13
+ export {
14
+ setDb,
15
+ getDb
16
+ };
17
+ //# sourceMappingURL=chunk-XANPEOJC.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/db/runtime.ts"],"sourcesContent":["import type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\n\n/**\n * Single source of truth for the runtime DB handle. Both the\n * collections pipeline and the media service read through `getDb()`\n * so test harnesses that swap the singleton via `setDb(testPool)`\n * affect every consumer in lockstep.\n *\n * Bootstrap glue (e.g. `@nexpress/next`'s `createBootstrap`) calls\n * `setDb()` once with the connection it created. Application code\n * does NOT call this directly — let the bootstrap do it.\n */\nlet dbInstance: NodePgDatabase<Record<string, unknown>> | null = null;\n\nexport function setDb(db: NodePgDatabase<Record<string, unknown>>): void {\n dbInstance = db;\n}\n\nexport function getDb(): NodePgDatabase<Record<string, unknown>> {\n if (!dbInstance) {\n throw new Error(\"Database not initialized. Call setDb() first.\");\n }\n return dbInstance;\n}\n"],"mappings":";AAYA,IAAI,aAA6D;AAE1D,SAAS,MAAM,IAAmD;AACvE,eAAa;AACf;AAEO,SAAS,QAAiD;AAC/D,MAAI,CAAC,YAAY;AACf,UAAM,IAAI,MAAM,+CAA+C;AAAA,EACjE;AACA,SAAO;AACT;","names":[]}
@@ -0,0 +1,83 @@
1
+ import {
2
+ runHook
3
+ } from "./chunk-VGTPQXNQ.js";
4
+ import {
5
+ getAllCollectionSlugs,
6
+ getCollectionConfig,
7
+ getCollectionTable
8
+ } from "./chunk-FZ7O6DWI.js";
9
+ import {
10
+ enqueueJob
11
+ } from "./chunk-V2UNHGAP.js";
12
+ import {
13
+ getDb
14
+ } from "./chunk-XANPEOJC.js";
15
+
16
+ // src/collections/scheduled.ts
17
+ import { and, eq, lt } from "drizzle-orm";
18
+ function hasPublishedAtField(fields) {
19
+ for (const field of fields) {
20
+ if (field.type === "row" || field.type === "collapsible") {
21
+ if (hasPublishedAtField(field.fields)) return true;
22
+ continue;
23
+ }
24
+ if (field.type === "group" || field.type === "array") {
25
+ if (hasPublishedAtField(field.fields)) return true;
26
+ continue;
27
+ }
28
+ if (field.type === "date" && field.name === "publishedAt") {
29
+ return true;
30
+ }
31
+ }
32
+ return false;
33
+ }
34
+ function getTableColumn(table, key) {
35
+ const column = table[key];
36
+ if (!column) {
37
+ throw new Error(`Column '${key}' not found on scheduled-publish table.`);
38
+ }
39
+ return column;
40
+ }
41
+ async function publishScheduledDocuments(atTime = /* @__PURE__ */ new Date()) {
42
+ const byCollection = {};
43
+ let published = 0;
44
+ for (const slug of getAllCollectionSlugs()) {
45
+ const config = getCollectionConfig(slug);
46
+ if (!hasPublishedAtField(config.fields)) continue;
47
+ const table = getCollectionTable(slug);
48
+ const statusCol = getTableColumn(table, "status");
49
+ const publishedAtCol = getTableColumn(table, "publishedAt");
50
+ const db = getDb();
51
+ const rows = await db.update(table).set({ status: "published", updatedAt: atTime }).where(and(eq(statusCol, "scheduled"), lt(publishedAtCol, atTime))).returning();
52
+ const ids = rows.map((row) => row.id);
53
+ byCollection[slug] = ids;
54
+ published += ids.length;
55
+ for (const row of rows) {
56
+ const docId = row.id;
57
+ await runHook("content:afterUpdate", {
58
+ collection: slug,
59
+ doc: row,
60
+ operation: "update",
61
+ scheduled: true
62
+ });
63
+ await runHook("content:afterPublish", {
64
+ collection: slug,
65
+ doc: row,
66
+ operation: "update",
67
+ scheduled: true
68
+ });
69
+ await enqueueJob("content:afterSave", {
70
+ collection: slug,
71
+ documentId: docId,
72
+ operation: "update",
73
+ userId: "scheduler"
74
+ });
75
+ }
76
+ }
77
+ return { published, byCollection };
78
+ }
79
+
80
+ export {
81
+ publishScheduledDocuments
82
+ };
83
+ //# sourceMappingURL=chunk-XPVQIHAQ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/collections/scheduled.ts"],"sourcesContent":["import { and, eq, lt } from \"drizzle-orm\";\nimport type { AnyPgColumn, PgTable } from \"drizzle-orm/pg-core\";\n\nimport type { NpFieldConfig } from \"../config/types.js\";\nimport { enqueueJob } from \"../jobs/queue.js\";\nimport { runHook } from \"../plugins/host.js\";\nimport {\n getAllCollectionSlugs,\n getCollectionConfig,\n getCollectionTable,\n} from \"./registry.js\";\nimport { getDb } from \"../db/runtime.js\";\n\nfunction hasPublishedAtField(fields: NpFieldConfig[]): boolean {\n for (const field of fields) {\n if (field.type === \"row\" || field.type === \"collapsible\") {\n if (hasPublishedAtField(field.fields)) return true;\n continue;\n }\n if (field.type === \"group\" || field.type === \"array\") {\n if (hasPublishedAtField(field.fields)) return true;\n continue;\n }\n if (field.type === \"date\" && field.name === \"publishedAt\") {\n return true;\n }\n }\n return false;\n}\n\nfunction getTableColumn(table: PgTable, key: string): AnyPgColumn {\n const column = (table as unknown as Record<string, unknown>)[key];\n if (!column) {\n throw new Error(`Column '${key}' not found on scheduled-publish table.`);\n }\n return column as AnyPgColumn;\n}\n\nexport interface PublishScheduledResult {\n published: number;\n byCollection: Record<string, string[]>;\n}\n\n/**\n * Scans every registered collection that has a `publishedAt` date field,\n * flips rows with `status=\"scheduled\"` whose `publishedAt <= now` to\n * `status=\"published\"`, and fires `content:afterPublish` for each.\n *\n * Safe to call repeatedly (idempotent once a doc is published) and cheap —\n * each UPDATE runs against an indexed status column and no-ops when empty.\n */\nexport async function publishScheduledDocuments(\n atTime: Date = new Date(),\n): Promise<PublishScheduledResult> {\n const byCollection: Record<string, string[]> = {};\n let published = 0;\n\n for (const slug of getAllCollectionSlugs()) {\n const config = getCollectionConfig(slug);\n if (!hasPublishedAtField(config.fields)) continue;\n\n const table = getCollectionTable(slug) as PgTable;\n const statusCol = getTableColumn(table, \"status\");\n const publishedAtCol = getTableColumn(table, \"publishedAt\");\n\n const db = getDb();\n // `.returning()` without args gives every column so plugin hooks get the\n // full doc they'd see from a normal update, not just { id }.\n const rows = (await db\n .update(table)\n .set({ status: \"published\", updatedAt: atTime })\n .where(and(eq(statusCol, \"scheduled\"), lt(publishedAtCol, atTime)))\n .returning()) as Array<Record<string, unknown>>;\n\n const ids = rows.map((row) => row.id as string);\n byCollection[slug] = ids;\n published += ids.length;\n\n for (const row of rows) {\n const docId = row.id as string;\n // Fire every hook a plugin would have seen if the user had clicked\n // Publish directly: afterUpdate (content changed), afterPublish\n // (status transitioned), and the afterSave job so revalidation +\n // collection-level afterUpdate hooks run too.\n await runHook(\"content:afterUpdate\", {\n collection: slug,\n doc: row,\n operation: \"update\",\n scheduled: true,\n });\n await runHook(\"content:afterPublish\", {\n collection: slug,\n doc: row,\n operation: \"update\",\n scheduled: true,\n });\n await enqueueJob(\"content:afterSave\", {\n collection: slug,\n documentId: docId,\n operation: \"update\",\n userId: \"scheduler\",\n });\n }\n }\n\n return { published, byCollection };\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAAA,SAAS,KAAK,IAAI,UAAU;AAa5B,SAAS,oBAAoB,QAAkC;AAC7D,aAAW,SAAS,QAAQ;AAC1B,QAAI,MAAM,SAAS,SAAS,MAAM,SAAS,eAAe;AACxD,UAAI,oBAAoB,MAAM,MAAM,EAAG,QAAO;AAC9C;AAAA,IACF;AACA,QAAI,MAAM,SAAS,WAAW,MAAM,SAAS,SAAS;AACpD,UAAI,oBAAoB,MAAM,MAAM,EAAG,QAAO;AAC9C;AAAA,IACF;AACA,QAAI,MAAM,SAAS,UAAU,MAAM,SAAS,eAAe;AACzD,aAAO;AAAA,IACT;AAAA,EACF;AACA,SAAO;AACT;AAEA,SAAS,eAAe,OAAgB,KAA0B;AAChE,QAAM,SAAU,MAA6C,GAAG;AAChE,MAAI,CAAC,QAAQ;AACX,UAAM,IAAI,MAAM,WAAW,GAAG,yCAAyC;AAAA,EACzE;AACA,SAAO;AACT;AAeA,eAAsB,0BACpB,SAAe,oBAAI,KAAK,GACS;AACjC,QAAM,eAAyC,CAAC;AAChD,MAAI,YAAY;AAEhB,aAAW,QAAQ,sBAAsB,GAAG;AAC1C,UAAM,SAAS,oBAAoB,IAAI;AACvC,QAAI,CAAC,oBAAoB,OAAO,MAAM,EAAG;AAEzC,UAAM,QAAQ,mBAAmB,IAAI;AACrC,UAAM,YAAY,eAAe,OAAO,QAAQ;AAChD,UAAM,iBAAiB,eAAe,OAAO,aAAa;AAE1D,UAAM,KAAK,MAAM;AAGjB,UAAM,OAAQ,MAAM,GACjB,OAAO,KAAK,EACZ,IAAI,EAAE,QAAQ,aAAa,WAAW,OAAO,CAAC,EAC9C,MAAM,IAAI,GAAG,WAAW,WAAW,GAAG,GAAG,gBAAgB,MAAM,CAAC,CAAC,EACjE,UAAU;AAEb,UAAM,MAAM,KAAK,IAAI,CAAC,QAAQ,IAAI,EAAY;AAC9C,iBAAa,IAAI,IAAI;AACrB,iBAAa,IAAI;AAEjB,eAAW,OAAO,MAAM;AACtB,YAAM,QAAQ,IAAI;AAKlB,YAAM,QAAQ,uBAAuB;AAAA,QACnC,YAAY;AAAA,QACZ,KAAK;AAAA,QACL,WAAW;AAAA,QACX,WAAW;AAAA,MACb,CAAC;AACD,YAAM,QAAQ,wBAAwB;AAAA,QACpC,YAAY;AAAA,QACZ,KAAK;AAAA,QACL,WAAW;AAAA,QACX,WAAW;AAAA,MACb,CAAC;AACD,YAAM,WAAW,qBAAqB;AAAA,QACpC,YAAY;AAAA,QACZ,YAAY;AAAA,QACZ,WAAW;AAAA,QACX,QAAQ;AAAA,MACV,CAAC;AAAA,IACH;AAAA,EACF;AAEA,SAAO,EAAE,WAAW,aAAa;AACnC;","names":[]}
@@ -0,0 +1,75 @@
1
+ // src/errors.ts
2
+ var NpError = class extends Error {
3
+ constructor(message, code, statusCode = 500) {
4
+ super(message);
5
+ this.code = code;
6
+ this.statusCode = statusCode;
7
+ this.name = "NpError";
8
+ }
9
+ code;
10
+ statusCode;
11
+ };
12
+ var NpForbiddenError = class extends NpError {
13
+ constructor(collection, operation) {
14
+ super(
15
+ `Access denied: ${operation} on ${collection}`,
16
+ "FORBIDDEN",
17
+ 403
18
+ );
19
+ this.name = "NpForbiddenError";
20
+ }
21
+ };
22
+ var NpNotFoundError = class extends NpError {
23
+ constructor(collection, id) {
24
+ super(
25
+ `Document not found: ${collection}/${id}`,
26
+ "NOT_FOUND",
27
+ 404
28
+ );
29
+ this.name = "NpNotFoundError";
30
+ }
31
+ };
32
+ var NpValidationError = class extends NpError {
33
+ constructor(message, errors) {
34
+ super(message, "VALIDATION_ERROR", 400);
35
+ this.errors = errors;
36
+ this.name = "NpValidationError";
37
+ }
38
+ errors;
39
+ };
40
+ var NpAuthError = class extends NpError {
41
+ constructor(message = "Unauthorized") {
42
+ super(message, "UNAUTHORIZED", 401);
43
+ this.name = "NpAuthError";
44
+ }
45
+ };
46
+ var NpConflictError = class extends NpError {
47
+ constructor(message) {
48
+ super(message, "CONFLICT", 409);
49
+ this.name = "NpConflictError";
50
+ }
51
+ };
52
+ var NpRateLimitError = class extends NpError {
53
+ constructor(message) {
54
+ super(message, "RATE_LIMITED", 429);
55
+ this.name = "NpRateLimitError";
56
+ }
57
+ };
58
+ var NpSiteContextMissingError = class extends NpError {
59
+ constructor(message) {
60
+ super(message, "SITE_CONTEXT_MISSING", 500);
61
+ this.name = "NpSiteContextMissingError";
62
+ }
63
+ };
64
+
65
+ export {
66
+ NpError,
67
+ NpForbiddenError,
68
+ NpNotFoundError,
69
+ NpValidationError,
70
+ NpAuthError,
71
+ NpConflictError,
72
+ NpRateLimitError,
73
+ NpSiteContextMissingError
74
+ };
75
+ //# sourceMappingURL=chunk-ZCINJSS4.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/errors.ts"],"sourcesContent":["/**\n * Public error-code contract (#290).\n *\n * `NpError.code` is the machine-readable string the API surface\n * sends to clients (`response.error.code`). The moment a client\n * branches on `error.code === \"VALIDATION_ERROR\"` it becomes\n * part of the public contract — adding a typo'd code or\n * renaming an existing one breaks every integration.\n *\n * `NpErrorCode` is the union of every code the framework\n * currently emits. Adding a new code requires extending this\n * union deliberately — no more \"casual\" string adoption that\n * accumulates over a year of PRs.\n *\n * The `NpError` constructor still accepts plain `string` so\n * out-of-tree plugins that throw their own codes keep working;\n * internal code paths use the union to get IntelliSense and\n * catch typos at compile time. The `(string & {})` trick on\n * the public type keeps editor completion narrow without\n * locking the runtime contract.\n *\n * **Stability**: codes follow semver. Renames or removals are\n * major-bump only. New codes may land in minor versions. See\n * `docs/api-error-codes.md` for the catalogue an operator /\n * client team should rely on.\n */\nexport type NpErrorCode =\n | \"FORBIDDEN\"\n | \"NOT_FOUND\"\n | \"VALIDATION_ERROR\"\n | \"UNAUTHORIZED\"\n | \"CONFLICT\"\n | \"RATE_LIMITED\"\n | \"TOO_MANY_REQUESTS\"\n | \"INVALID_URL\"\n | \"EMAIL_ADAPTER_MISSING_DEPENDENCY\"\n | \"EMAIL_DELIVERY_FAILED\"\n | \"SITE_CONTEXT_MISSING\"\n | \"INTERNAL_ERROR\";\n\n/**\n * The constructor signature accepts the union *or* an arbitrary\n * string. Inside the codebase, passing a literal that isn't in\n * the union triggers a TypeScript error in strict editors\n * (autocompletion narrows to `NpErrorCode`). External plugins\n * authoring their own codes keep working — they just won't get\n * the autocomplete win.\n */\nexport type NpErrorCodeInput = NpErrorCode | (string & Record<never, never>);\n\nexport class NpError extends Error {\n constructor(\n message: string,\n public readonly code: NpErrorCodeInput,\n public readonly statusCode: number = 500,\n ) {\n super(message);\n this.name = \"NpError\";\n }\n}\n\nexport class NpForbiddenError extends NpError {\n constructor(collection: string, operation: string) {\n super(\n `Access denied: ${operation} on ${collection}`,\n \"FORBIDDEN\",\n 403,\n );\n this.name = \"NpForbiddenError\";\n }\n}\n\nexport class NpNotFoundError extends NpError {\n constructor(collection: string, id: string) {\n super(\n `Document not found: ${collection}/${id}`,\n \"NOT_FOUND\",\n 404,\n );\n this.name = \"NpNotFoundError\";\n }\n}\n\nexport class NpValidationError extends NpError {\n constructor(\n message: string,\n public readonly errors: Array<{ field: string; message: string }>,\n ) {\n super(message, \"VALIDATION_ERROR\", 400);\n this.name = \"NpValidationError\";\n }\n}\n\nexport class NpAuthError extends NpError {\n constructor(message: string = \"Unauthorized\") {\n super(message, \"UNAUTHORIZED\", 401);\n this.name = \"NpAuthError\";\n }\n}\n\nexport class NpConflictError extends NpError {\n constructor(message: string) {\n super(message, \"CONFLICT\", 409);\n this.name = \"NpConflictError\";\n }\n}\n\n/**\n * Per-actor rate limit / quota exceeded. Distinct from\n * `NpValidationError` because the request shape was valid — the\n * server is rejecting it on policy grounds. The 429 status lets\n * client UIs recognize the case and surface a \"you've hit your\n * daily limit\" message rather than a generic validation error.\n */\nexport class NpRateLimitError extends NpError {\n constructor(message: string) {\n super(message, \"RATE_LIMITED\", 429);\n this.name = \"NpRateLimitError\";\n }\n}\n\n/**\n * No site context resolved when one was required (#272). Thrown\n * by `requireSiteId()` on write paths. 500 because this is a\n * server-side wiring bug — the user request was well-formed,\n * but the framework couldn't tell which tenant to write to.\n * Promoted from a plain `Error` to a real `NpError` subclass so\n * the API layer surfaces it as a uniform error envelope and\n * clients can branch on the stable `SITE_CONTEXT_MISSING` code.\n */\nexport class NpSiteContextMissingError extends NpError {\n constructor(message: string) {\n super(message, \"SITE_CONTEXT_MISSING\", 500);\n this.name = \"NpSiteContextMissingError\";\n }\n}\n"],"mappings":";AAkDO,IAAM,UAAN,cAAsB,MAAM;AAAA,EACjC,YACE,SACgB,MACA,aAAqB,KACrC;AACA,UAAM,OAAO;AAHG;AACA;AAGhB,SAAK,OAAO;AAAA,EACd;AAAA,EALkB;AAAA,EACA;AAKpB;AAEO,IAAM,mBAAN,cAA+B,QAAQ;AAAA,EAC5C,YAAY,YAAoB,WAAmB;AACjD;AAAA,MACE,kBAAkB,SAAS,OAAO,UAAU;AAAA,MAC5C;AAAA,MACA;AAAA,IACF;AACA,SAAK,OAAO;AAAA,EACd;AACF;AAEO,IAAM,kBAAN,cAA8B,QAAQ;AAAA,EAC3C,YAAY,YAAoB,IAAY;AAC1C;AAAA,MACE,uBAAuB,UAAU,IAAI,EAAE;AAAA,MACvC;AAAA,MACA;AAAA,IACF;AACA,SAAK,OAAO;AAAA,EACd;AACF;AAEO,IAAM,oBAAN,cAAgC,QAAQ;AAAA,EAC7C,YACE,SACgB,QAChB;AACA,UAAM,SAAS,oBAAoB,GAAG;AAFtB;AAGhB,SAAK,OAAO;AAAA,EACd;AAAA,EAJkB;AAKpB;AAEO,IAAM,cAAN,cAA0B,QAAQ;AAAA,EACvC,YAAY,UAAkB,gBAAgB;AAC5C,UAAM,SAAS,gBAAgB,GAAG;AAClC,SAAK,OAAO;AAAA,EACd;AACF;AAEO,IAAM,kBAAN,cAA8B,QAAQ;AAAA,EAC3C,YAAY,SAAiB;AAC3B,UAAM,SAAS,YAAY,GAAG;AAC9B,SAAK,OAAO;AAAA,EACd;AACF;AASO,IAAM,mBAAN,cAA+B,QAAQ;AAAA,EAC5C,YAAY,SAAiB;AAC3B,UAAM,SAAS,gBAAgB,GAAG;AAClC,SAAK,OAAO;AAAA,EACd;AACF;AAWO,IAAM,4BAAN,cAAwC,QAAQ;AAAA,EACrD,YAAY,SAAiB;AAC3B,UAAM,SAAS,wBAAwB,GAAG;AAC1C,SAAK,OAAO;AAAA,EACd;AACF;","names":[]}