alepha 0.19.2 → 0.19.4

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 (241) hide show
  1. package/assets/swagger-ui/swagger-ui-bundle.js +1 -1
  2. package/dist/api/audits/index.d.ts +8 -8
  3. package/dist/api/invitations/index.d.ts +790 -0
  4. package/dist/api/invitations/index.d.ts.map +1 -0
  5. package/dist/api/invitations/index.js +665 -0
  6. package/dist/api/invitations/index.js.map +1 -0
  7. package/dist/api/jobs/index.browser.js +8 -9
  8. package/dist/api/jobs/index.browser.js.map +1 -1
  9. package/dist/api/jobs/index.d.ts +90 -34
  10. package/dist/api/jobs/index.d.ts.map +1 -1
  11. package/dist/api/jobs/index.js +267 -44
  12. package/dist/api/jobs/index.js.map +1 -1
  13. package/dist/api/notifications/index.browser.js +0 -1
  14. package/dist/api/notifications/index.browser.js.map +1 -1
  15. package/dist/api/notifications/index.d.ts +3 -3
  16. package/dist/api/notifications/index.d.ts.map +1 -1
  17. package/dist/api/notifications/index.js +0 -1
  18. package/dist/api/notifications/index.js.map +1 -1
  19. package/dist/api/parameters/index.browser.js +112 -1
  20. package/dist/api/parameters/index.browser.js.map +1 -1
  21. package/dist/api/parameters/index.d.ts +90 -3
  22. package/dist/api/parameters/index.d.ts.map +1 -1
  23. package/dist/api/parameters/index.js +79 -12
  24. package/dist/api/parameters/index.js.map +1 -1
  25. package/dist/{billing → api/payments}/index.d.ts +67 -49
  26. package/dist/api/payments/index.d.ts.map +1 -0
  27. package/dist/{billing → api/payments}/index.js +108 -74
  28. package/dist/api/payments/index.js.map +1 -0
  29. package/dist/api/subscriptions/index.d.ts +1692 -0
  30. package/dist/api/subscriptions/index.d.ts.map +1 -0
  31. package/dist/api/subscriptions/index.js +1870 -0
  32. package/dist/api/subscriptions/index.js.map +1 -0
  33. package/dist/api/users/index.d.ts +27 -21
  34. package/dist/api/users/index.d.ts.map +1 -1
  35. package/dist/api/users/index.js +167 -34
  36. package/dist/api/users/index.js.map +1 -1
  37. package/dist/api/workflows/index.browser.js +246 -0
  38. package/dist/api/workflows/index.browser.js.map +1 -0
  39. package/dist/api/workflows/index.d.ts +1618 -0
  40. package/dist/api/workflows/index.d.ts.map +1 -0
  41. package/dist/api/workflows/index.js +1504 -0
  42. package/dist/api/workflows/index.js.map +1 -0
  43. package/dist/cli/config/index.d.ts +6 -28
  44. package/dist/cli/config/index.d.ts.map +1 -1
  45. package/dist/cli/config/index.js +5 -10
  46. package/dist/cli/config/index.js.map +1 -1
  47. package/dist/cli/core/index.d.ts +11669 -208
  48. package/dist/cli/core/index.d.ts.map +1 -1
  49. package/dist/cli/core/index.js +60 -69
  50. package/dist/cli/core/index.js.map +1 -1
  51. package/dist/cli/devtools/index.d.ts +5 -0
  52. package/dist/cli/devtools/index.d.ts.map +1 -1
  53. package/dist/cli/devtools/index.js +4 -0
  54. package/dist/cli/devtools/index.js.map +1 -1
  55. package/dist/cli/platform/index.d.ts +69 -64
  56. package/dist/cli/platform/index.d.ts.map +1 -1
  57. package/dist/cli/platform/index.js +6 -2
  58. package/dist/cli/platform/index.js.map +1 -1
  59. package/dist/cli/vendor/index.d.ts +38 -10
  60. package/dist/cli/vendor/index.d.ts.map +1 -1
  61. package/dist/cli/vendor/index.js +85 -26
  62. package/dist/cli/vendor/index.js.map +1 -1
  63. package/dist/core/index.browser.js +21 -2
  64. package/dist/core/index.browser.js.map +1 -1
  65. package/dist/core/index.d.ts +33 -2
  66. package/dist/core/index.d.ts.map +1 -1
  67. package/dist/core/index.js +25 -2
  68. package/dist/core/index.js.map +1 -1
  69. package/dist/core/index.native.js +25 -2
  70. package/dist/core/index.native.js.map +1 -1
  71. package/dist/core/index.workerd.js +25 -2
  72. package/dist/core/index.workerd.js.map +1 -1
  73. package/dist/email/smtp/index.js +24 -8
  74. package/dist/email/smtp/index.js.map +1 -1
  75. package/dist/logger/index.d.ts.map +1 -1
  76. package/dist/logger/index.js +1 -1
  77. package/dist/logger/index.js.map +1 -1
  78. package/dist/orm/core/index.browser.js +0 -18
  79. package/dist/orm/core/index.browser.js.map +1 -1
  80. package/dist/orm/core/index.bun.js +25 -73
  81. package/dist/orm/core/index.bun.js.map +1 -1
  82. package/dist/orm/core/index.d.ts +10 -32
  83. package/dist/orm/core/index.d.ts.map +1 -1
  84. package/dist/orm/core/index.js +25 -73
  85. package/dist/orm/core/index.js.map +1 -1
  86. package/dist/orm/postgres/index.bun.js +3 -3
  87. package/dist/orm/postgres/index.bun.js.map +1 -1
  88. package/dist/orm/postgres/index.d.ts +2 -1
  89. package/dist/orm/postgres/index.d.ts.map +1 -1
  90. package/dist/orm/postgres/index.js +3 -3
  91. package/dist/orm/postgres/index.js.map +1 -1
  92. package/dist/react/router/index.browser.js +25 -3
  93. package/dist/react/router/index.browser.js.map +1 -1
  94. package/dist/react/router/index.d.ts +16 -1
  95. package/dist/react/router/index.d.ts.map +1 -1
  96. package/dist/react/router/index.js +25 -3
  97. package/dist/react/router/index.js.map +1 -1
  98. package/dist/security/index.d.ts +28 -0
  99. package/dist/security/index.d.ts.map +1 -1
  100. package/dist/security/index.js +28 -0
  101. package/dist/security/index.js.map +1 -1
  102. package/package.json +37 -20
  103. package/src/api/invitations/__tests__/InvitationService.spec.ts +439 -0
  104. package/src/api/invitations/controllers/AdminInvitationController.ts +86 -0
  105. package/src/api/invitations/controllers/InvitationController.ts +84 -0
  106. package/src/api/invitations/entities/invitations.ts +33 -0
  107. package/src/api/invitations/index.ts +65 -0
  108. package/src/api/invitations/jobs/InvitationJobs.ts +37 -0
  109. package/src/api/invitations/providers/InvitationProvider.ts +45 -0
  110. package/src/api/invitations/schemas/createInvitationSchema.ts +12 -0
  111. package/src/api/invitations/schemas/invitationConfigAtom.ts +20 -0
  112. package/src/api/invitations/schemas/invitationQuerySchema.ts +15 -0
  113. package/src/api/invitations/schemas/invitationResourceSchema.ts +6 -0
  114. package/src/api/invitations/schemas/invitationWithResourceInfoSchema.ts +22 -0
  115. package/src/api/invitations/schemas/myInvitationsQuerySchema.ts +10 -0
  116. package/src/api/invitations/services/InvitationService.ts +556 -0
  117. package/src/api/jobs/__tests__/$job.spec.ts +876 -0
  118. package/src/api/jobs/controllers/AdminJobController.ts +44 -0
  119. package/src/api/jobs/entities/jobExecutionEntity.ts +0 -2
  120. package/src/api/jobs/index.ts +0 -3
  121. package/src/api/jobs/primitives/$job.ts +22 -11
  122. package/src/api/jobs/providers/JobProvider.ts +239 -25
  123. package/src/api/jobs/schemas/jobConfigAtom.ts +4 -0
  124. package/src/api/jobs/schemas/jobCronInfoSchema.ts +1 -0
  125. package/src/api/jobs/schemas/jobExecutionQuerySchema.ts +0 -1
  126. package/src/api/jobs/schemas/jobQueueDepthSchema.ts +1 -0
  127. package/src/api/jobs/schemas/jobRegistrationSchema.ts +1 -6
  128. package/src/api/jobs/services/JobService.ts +51 -12
  129. package/src/api/notifications/schemas/notificationQuerySchema.ts +0 -1
  130. package/src/api/parameters/__tests__/$parameter.spec.ts +327 -0
  131. package/src/api/parameters/controllers/AdminParameterController.ts +29 -3
  132. package/src/api/parameters/index.browser.ts +12 -0
  133. package/src/api/parameters/primitives/$parameter.ts +20 -3
  134. package/src/api/parameters/services/ParameterProvider.ts +48 -7
  135. package/src/{billing → api/payments}/__tests__/PaymentMethodService.spec.ts +32 -6
  136. package/src/api/payments/__tests__/PaymentService.spec.ts +279 -0
  137. package/src/{billing/controllers/AdminBillingController.ts → api/payments/controllers/AdminPaymentController.ts} +26 -21
  138. package/src/{billing/controllers/BillingController.ts → api/payments/controllers/PaymentController.ts} +23 -11
  139. package/src/{billing → api/payments}/entities/paymentIntents.ts +1 -0
  140. package/src/{billing/errors/BillingError.ts → api/payments/errors/PaymentError.ts} +1 -1
  141. package/src/{billing → api/payments}/index.ts +31 -25
  142. package/src/{billing/providers/MemoryBillingProvider.ts → api/payments/providers/MemoryPaymentProvider.ts} +4 -4
  143. package/src/{billing/providers/BillingProvider.ts → api/payments/providers/PaymentProvider.ts} +9 -2
  144. package/src/{billing → api/payments}/services/PaymentMethodService.ts +5 -5
  145. package/src/{billing/services/BillingService.ts → api/payments/services/PaymentService.ts} +94 -18
  146. package/src/api/subscriptions/__tests__/BillingService.spec.ts +218 -0
  147. package/src/api/subscriptions/__tests__/SubscriptionService.spec.ts +278 -0
  148. package/src/api/subscriptions/controllers/AdminSubscriptionController.ts +212 -0
  149. package/src/api/subscriptions/controllers/SubscriptionController.ts +189 -0
  150. package/src/api/subscriptions/entities/subscriptionEvents.ts +54 -0
  151. package/src/api/subscriptions/entities/subscriptions.ts +68 -0
  152. package/src/api/subscriptions/index.ts +144 -0
  153. package/src/api/subscriptions/jobs/SubscriptionJobs.ts +382 -0
  154. package/src/api/subscriptions/middleware/$requireLimit.ts +50 -0
  155. package/src/api/subscriptions/middleware/$requirePlan.ts +49 -0
  156. package/src/api/subscriptions/notifications/SubscriptionNotifications.ts +110 -0
  157. package/src/api/subscriptions/schemas/cancelSubscriptionSchema.ts +8 -0
  158. package/src/api/subscriptions/schemas/changePlanSchema.ts +9 -0
  159. package/src/api/subscriptions/schemas/createSubscriptionSchema.ts +11 -0
  160. package/src/api/subscriptions/schemas/entitlementsSchema.ts +21 -0
  161. package/src/api/subscriptions/schemas/mrrSchema.ts +13 -0
  162. package/src/api/subscriptions/schemas/planDefinitionSchema.ts +71 -0
  163. package/src/api/subscriptions/schemas/planResourceSchema.ts +25 -0
  164. package/src/api/subscriptions/schemas/subscriptionEventResourceSchema.ts +8 -0
  165. package/src/api/subscriptions/schemas/subscriptionQuerySchema.ts +19 -0
  166. package/src/api/subscriptions/schemas/subscriptionResourceSchema.ts +6 -0
  167. package/src/api/subscriptions/schemas/subscriptionSettingsSchema.ts +32 -0
  168. package/src/api/subscriptions/schemas/subscriptionStatsSchema.ts +23 -0
  169. package/src/api/subscriptions/services/BillingService.ts +437 -0
  170. package/src/api/subscriptions/services/SubscriptionConfig.ts +56 -0
  171. package/src/api/subscriptions/services/SubscriptionService.ts +867 -0
  172. package/src/api/subscriptions/services/UsageService.ts +118 -0
  173. package/src/api/users/__tests__/AdminUserController.spec.ts +80 -1
  174. package/src/api/users/__tests__/CredentialService.spec.ts +177 -0
  175. package/src/api/users/__tests__/EmailVerification.spec.ts +29 -18
  176. package/src/api/users/__tests__/PasswordReset.spec.ts +3 -0
  177. package/src/api/users/__tests__/RegistrationService.spec.ts +148 -1
  178. package/src/api/users/__tests__/SessionService.spec.ts +142 -1
  179. package/src/api/users/atoms/realmAuthSettingsAtom.ts +10 -1
  180. package/src/api/users/controllers/UserController.ts +3 -8
  181. package/src/api/users/notifications/UserNotifications.ts +23 -0
  182. package/src/api/users/schemas/loginSchema.ts +1 -1
  183. package/src/api/users/services/CredentialService.ts +51 -4
  184. package/src/api/users/services/RegistrationService.ts +38 -9
  185. package/src/api/users/services/SessionService.ts +62 -9
  186. package/src/api/users/services/UserService.ts +21 -12
  187. package/src/api/workflows/__tests__/$workflow.spec.ts +616 -0
  188. package/src/api/workflows/controllers/AdminWorkflowController.ts +191 -0
  189. package/src/api/workflows/entities/workflowExecutions.ts +74 -0
  190. package/src/api/workflows/entities/workflowStepExecutions.ts +74 -0
  191. package/src/api/workflows/entities/workflowStepLogs.ts +13 -0
  192. package/src/api/workflows/index.browser.ts +22 -0
  193. package/src/api/workflows/index.ts +124 -0
  194. package/src/api/workflows/jobs/WorkflowJobs.ts +77 -0
  195. package/src/api/workflows/primitives/$workflow.ts +202 -0
  196. package/src/api/workflows/providers/WorkflowProvider.ts +1284 -0
  197. package/src/api/workflows/schemas/workflowActivitySchema.ts +15 -0
  198. package/src/api/workflows/schemas/workflowConfigAtom.ts +51 -0
  199. package/src/api/workflows/schemas/workflowExecutionDetailSchema.ts +18 -0
  200. package/src/api/workflows/schemas/workflowExecutionQuerySchema.ts +26 -0
  201. package/src/api/workflows/schemas/workflowExecutionResourceSchema.ts +30 -0
  202. package/src/api/workflows/schemas/workflowRegistrationSchema.ts +26 -0
  203. package/src/api/workflows/schemas/workflowStatsSchema.ts +16 -0
  204. package/src/api/workflows/schemas/workflowStepExecutionResourceSchema.ts +15 -0
  205. package/src/api/workflows/services/WorkflowService.ts +382 -0
  206. package/src/cli/config/defineConfig.ts +17 -46
  207. package/src/cli/core/providers/ViteDevServerProvider.ts +45 -3
  208. package/src/cli/core/services/PackageManagerUtils.ts +3 -1
  209. package/src/cli/core/services/ProjectScaffolder.ts +5 -5
  210. package/src/cli/core/templates/agentMd.ts +14 -5
  211. package/src/cli/core/templates/webAppRouterTs.ts +5 -58
  212. package/src/cli/devtools/index.ts +21 -1
  213. package/src/cli/platform/index.ts +23 -2
  214. package/src/cli/vendor/__tests__/VendorService.spec.ts +283 -178
  215. package/src/cli/vendor/index.ts +20 -3
  216. package/src/cli/vendor/services/VendorService.ts +126 -27
  217. package/src/core/Alepha.ts +10 -0
  218. package/src/core/__tests__/TypeProvider.spec.ts +4 -2
  219. package/src/core/providers/SchemaValidator.ts +1 -1
  220. package/src/core/providers/TypeProvider.ts +46 -3
  221. package/src/logger/index.ts +6 -1
  222. package/src/orm/__tests__/enums.spec.ts +22 -29
  223. package/src/orm/__tests__/orm-showcase-tests.ts +430 -0
  224. package/src/orm/__tests__/orm-showcase.spec.ts +167 -0
  225. package/src/orm/core/providers/DatabaseTypeProvider.ts +0 -29
  226. package/src/orm/core/providers/DrizzleKitProvider.ts +56 -105
  227. package/src/orm/postgres/services/PostgresModelBuilder.ts +3 -6
  228. package/src/react/router/__tests__/$page.browser.spec.tsx +157 -0
  229. package/src/react/router/providers/ReactBrowserProvider.ts +39 -0
  230. package/src/react/router/providers/ReactBrowserRouterProvider.ts +22 -0
  231. package/src/security/__tests__/$secure-combinations.spec.ts +945 -0
  232. package/src/security/primitives/$secure.ts +28 -0
  233. package/tsconfig.base.json +0 -1
  234. package/dist/billing/index.d.ts.map +0 -1
  235. package/dist/billing/index.js.map +0 -1
  236. package/src/billing/__tests__/BillingService.spec.ts +0 -136
  237. /package/src/{billing → api/payments}/entities/paymentMethods.ts +0 -0
  238. /package/src/{billing → api/payments}/entities/refunds.ts +0 -0
  239. /package/src/{billing → api/payments}/schemas/intentSchemas.ts +0 -0
  240. /package/src/{billing → api/payments}/schemas/paymentMethodSchemas.ts +0 -0
  241. /package/src/{billing → api/payments}/schemas/refundSchemas.ts +0 -0
@@ -68,7 +68,7 @@ export class JobService {
68
68
  COUNT(*) FILTER (WHERE ${e.status} = 'retrying') AS retrying,
69
69
  COUNT(*) FILTER (WHERE ${e.status} = 'dead') AS dead,
70
70
  COUNT(*) FILTER (WHERE ${e.status} = 'completed' AND ${e.completedAt} >= ${periodAgo}) AS completed_24h,
71
- COUNT(*) FILTER (WHERE ${e.status} IN ('dead', 'failed') AND ${e.completedAt} >= ${periodAgo}) AS failed_24h
71
+ COUNT(*) FILTER (WHERE ${e.status} = 'dead' AND ${e.completedAt} >= ${periodAgo}) AS failed_24h
72
72
  FROM ${e}
73
73
  `,
74
74
  t.object({
@@ -127,12 +127,7 @@ export class JobService {
127
127
  hasBackoff: Boolean(opts.retry.backoff),
128
128
  }
129
129
  : undefined,
130
- batch: opts.batch
131
- ? {
132
- size: opts.batch.size,
133
- window: String(opts.batch.window),
134
- }
135
- : undefined,
130
+ paused: this.jobProvider.isJobPaused(name),
136
131
  };
137
132
 
138
133
  result.push(registration);
@@ -278,6 +273,48 @@ export class JobService {
278
273
  return { ok: true };
279
274
  }
280
275
 
276
+ public pauseJob(
277
+ name: string,
278
+ context?: { pausedBy?: string; pausedByName?: string },
279
+ ): { ok: boolean } {
280
+ const jobPrimitives = this.alepha.primitives($job);
281
+ const job = jobPrimitives.find((j) => j.name === name);
282
+
283
+ if (!job) {
284
+ throw new NotFoundError(`Job not found: ${name}`);
285
+ }
286
+
287
+ this.log.info(`Pausing job '${name}'`, {
288
+ pausedBy: context?.pausedByName ?? context?.pausedBy,
289
+ });
290
+
291
+ job.pause();
292
+ return { ok: true };
293
+ }
294
+
295
+ public async resumeJob(
296
+ name: string,
297
+ context?: { resumedBy?: string; resumedByName?: string },
298
+ ): Promise<{ ok: boolean }> {
299
+ const jobPrimitives = this.alepha.primitives($job);
300
+ const job = jobPrimitives.find((j) => j.name === name);
301
+
302
+ if (!job) {
303
+ throw new NotFoundError(`Job not found: ${name}`);
304
+ }
305
+
306
+ this.log.info(`Resuming job '${name}'`, {
307
+ resumedBy: context?.resumedByName ?? context?.resumedBy,
308
+ });
309
+
310
+ await job.resume();
311
+ return { ok: true };
312
+ }
313
+
314
+ public getPausedJobs(): string[] {
315
+ return this.jobProvider.getPausedJobs();
316
+ }
317
+
281
318
  public async getCronJobs(): Promise<JobCronInfo[]> {
282
319
  const jobs = this.jobProvider.getRegisteredJobs();
283
320
  const cronJobNames: string[] = [];
@@ -301,6 +338,7 @@ export class JobService {
301
338
  priority: (opts.priority ?? "normal") as JobCronInfo["priority"],
302
339
  concurrency: opts.concurrency ?? 1,
303
340
  hasSchema: Boolean(opts.schema),
341
+ paused: this.jobProvider.isJobPaused(name),
304
342
  lastExecution: last
305
343
  ? {
306
344
  id: last.id,
@@ -355,6 +393,7 @@ export class JobService {
355
393
  retrying: Number(row?.retrying ?? 0),
356
394
  dead: Number(row?.dead ?? 0),
357
395
  concurrency: reg.options.concurrency ?? 1,
396
+ paused: this.jobProvider.isJobPaused(name),
358
397
  });
359
398
  }
360
399
 
@@ -378,10 +417,10 @@ export class JobService {
378
417
  SELECT
379
418
  ds.date::text AS date,
380
419
  COALESCE(COUNT(*) FILTER (WHERE ${e.status} = 'completed'), 0) AS completed,
381
- COALESCE(COUNT(*) FILTER (WHERE ${e.status} IN ('dead', 'failed')), 0) AS failed
420
+ COALESCE(COUNT(*) FILTER (WHERE ${e.status} = 'dead'), 0) AS failed
382
421
  FROM date_series ds
383
422
  LEFT JOIN ${e} ON DATE(${e.completedAt}) = ds.date
384
- AND ${e.status} IN ('completed', 'dead', 'failed')
423
+ AND ${e.status} IN ('completed', 'dead')
385
424
  GROUP BY ds.date
386
425
  ORDER BY ds.date ASC
387
426
  `,
@@ -404,7 +443,7 @@ export class JobService {
404
443
  const startDate = now.subtract(days - 1, "day");
405
444
 
406
445
  const where = this.executions.createQueryWhere();
407
- where.status = { inArray: ["completed", "dead", "failed"] };
446
+ where.status = { inArray: ["completed", "dead"] };
408
447
  where.completedAt = { gte: startDate.startOf("day").toISOString() };
409
448
 
410
449
  const executions = await this.executions.findMany({ where });
@@ -448,7 +487,7 @@ export class JobService {
448
487
  COUNT(*) AS failures,
449
488
  (ARRAY_AGG(${e.error} ORDER BY ${e.completedAt} DESC))[1] AS last_error
450
489
  FROM ${e}
451
- WHERE ${e.status} IN ('dead', 'failed')
490
+ WHERE ${e.status} = 'dead'
452
491
  AND ${e.completedAt} >= ${periodAgoIso}
453
492
  GROUP BY ${e.jobName}
454
493
  ORDER BY failures DESC
@@ -471,7 +510,7 @@ export class JobService {
471
510
  periodAgoIso: string,
472
511
  ): Promise<JobFailure[]> {
473
512
  const where = this.executions.createQueryWhere();
474
- where.status = { inArray: ["dead", "failed"] };
513
+ where.status = { eq: "dead" };
475
514
  where.completedAt = { gte: periodAgoIso };
476
515
 
477
516
  const failures = await this.executions.findMany({
@@ -9,7 +9,6 @@ export const notificationQuerySchema = t.extend(pageQuerySchema, {
9
9
  "retrying",
10
10
  "running",
11
11
  "completed",
12
- "failed",
13
12
  "dead",
14
13
  "cancelled",
15
14
  ]),
@@ -1902,3 +1902,330 @@ describe("ParameterTreeNode children structure", () => {
1902
1902
  expect(aNode.children.map((c: any) => c.name).sort()).toEqual(["b", "c"]);
1903
1903
  });
1904
1904
  });
1905
+
1906
+ // ---------------------------------------------------------------------------
1907
+ // Content validation in save()
1908
+ // ---------------------------------------------------------------------------
1909
+
1910
+ describe("save validation", () => {
1911
+ it("should reject invalid content when schema hash matches", async () => {
1912
+ class AppConfig {
1913
+ features = $parameter({
1914
+ name: "validate.reject",
1915
+ schema: featureSchema,
1916
+ default: { enableBeta: false, maxUploadSize: 10485760 },
1917
+ });
1918
+ }
1919
+
1920
+ const alepha = Alepha.create().with(AlephaOrmPostgres);
1921
+ alepha.with(AlephaApiParameters);
1922
+ alepha.with(AppConfig);
1923
+ await alepha.start();
1924
+
1925
+ const provider = alepha.inject(ParameterProvider);
1926
+ const schemaHash = (provider as any).schemaHashes.get("validate.reject");
1927
+
1928
+ // Invalid: maxUploadSize should be number, not string
1929
+ await expect(
1930
+ provider.save(
1931
+ "validate.reject",
1932
+ { enableBeta: true, maxUploadSize: "not-a-number" } as any,
1933
+ schemaHash,
1934
+ ),
1935
+ ).rejects.toThrow();
1936
+ });
1937
+
1938
+ it("should accept valid content when schema hash matches", async () => {
1939
+ class AppConfig {
1940
+ features = $parameter({
1941
+ name: "validate.accept",
1942
+ schema: featureSchema,
1943
+ default: { enableBeta: false, maxUploadSize: 10485760 },
1944
+ });
1945
+ }
1946
+
1947
+ const alepha = Alepha.create().with(AlephaOrmPostgres);
1948
+ alepha.with(AlephaApiParameters);
1949
+ alepha.with(AppConfig);
1950
+ await alepha.start();
1951
+
1952
+ const provider = alepha.inject(ParameterProvider);
1953
+ const schemaHash = (provider as any).schemaHashes.get("validate.accept");
1954
+
1955
+ const result = await provider.save(
1956
+ "validate.accept",
1957
+ { enableBeta: true, maxUploadSize: 999 },
1958
+ schemaHash,
1959
+ );
1960
+
1961
+ expect(result.content).toEqual({ enableBeta: true, maxUploadSize: 999 });
1962
+ });
1963
+
1964
+ it("should skip validation when schema hash differs (migration seed)", async () => {
1965
+ class AppConfig {
1966
+ features = $parameter({
1967
+ name: "validate.skip",
1968
+ schema: featureSchema,
1969
+ default: { enableBeta: false, maxUploadSize: 10485760 },
1970
+ });
1971
+ }
1972
+
1973
+ const alepha = Alepha.create().with(AlephaOrmPostgres);
1974
+ alepha.with(AlephaApiParameters);
1975
+ alepha.with(AppConfig);
1976
+ await alepha.start();
1977
+
1978
+ const provider = alepha.inject(ParameterProvider);
1979
+
1980
+ // Save with mismatched hash — should NOT validate against current schema
1981
+ const result = await provider.save(
1982
+ "validate.skip",
1983
+ { totally: "different", shape: 42 } as any,
1984
+ "old-hash",
1985
+ );
1986
+
1987
+ expect(result.content).toEqual({ totally: "different", shape: 42 });
1988
+ });
1989
+ });
1990
+
1991
+ // ---------------------------------------------------------------------------
1992
+ // $parameter.name fallback
1993
+ // ---------------------------------------------------------------------------
1994
+
1995
+ describe("$parameter.name fallback", () => {
1996
+ it("should use ClassName.propertyKey when name option is omitted", async () => {
1997
+ class MySettings {
1998
+ theme = $parameter({
1999
+ schema: t.object({ dark: t.boolean() }),
2000
+ default: { dark: false },
2001
+ });
2002
+ }
2003
+
2004
+ const alepha = Alepha.create().with(AlephaOrmPostgres);
2005
+ alepha.with(AlephaApiParameters);
2006
+ alepha.with(MySettings);
2007
+ await alepha.start();
2008
+
2009
+ const settings = alepha.inject(MySettings);
2010
+ expect(settings.theme.name).toBe("MySettings.theme");
2011
+ });
2012
+
2013
+ it("should use explicit name when provided", async () => {
2014
+ class MySettings {
2015
+ theme = $parameter({
2016
+ name: "app.ui.theme",
2017
+ schema: t.object({ dark: t.boolean() }),
2018
+ default: { dark: false },
2019
+ });
2020
+ }
2021
+
2022
+ const alepha = Alepha.create().with(AlephaOrmPostgres);
2023
+ alepha.with(AlephaApiParameters);
2024
+ alepha.with(MySettings);
2025
+ await alepha.start();
2026
+
2027
+ const settings = alepha.inject(MySettings);
2028
+ expect(settings.theme.name).toBe("app.ui.theme");
2029
+ });
2030
+ });
2031
+
2032
+ // ---------------------------------------------------------------------------
2033
+ // delete()
2034
+ // ---------------------------------------------------------------------------
2035
+
2036
+ describe("delete", () => {
2037
+ it("should delete all versions and clear cache", async () => {
2038
+ class AppConfig {
2039
+ features = $parameter({
2040
+ name: "delete.test",
2041
+ schema: featureSchema,
2042
+ default: { enableBeta: false, maxUploadSize: 10485760 },
2043
+ });
2044
+ }
2045
+
2046
+ const alepha = Alepha.create().with(AlephaOrmPostgres);
2047
+ alepha.with(AlephaApiParameters);
2048
+ alepha.with(AppConfig);
2049
+ await alepha.start();
2050
+
2051
+ const config = alepha.inject(AppConfig);
2052
+ const provider = alepha.inject(ParameterProvider);
2053
+
2054
+ await config.features.set({ enableBeta: true, maxUploadSize: 100 });
2055
+ await config.features.set({ enableBeta: false, maxUploadSize: 200 });
2056
+
2057
+ const historyBefore = await provider.getHistory("delete.test");
2058
+ expect(historyBefore.length).toBe(2);
2059
+
2060
+ await config.features.delete();
2061
+
2062
+ // All versions gone
2063
+ const historyAfter = await provider.getHistory("delete.test");
2064
+ expect(historyAfter.length).toBe(0);
2065
+
2066
+ // Cache cleared — falls back to default
2067
+ expect(await config.features.get()).toEqual({
2068
+ enableBeta: false,
2069
+ maxUploadSize: 10485760,
2070
+ });
2071
+ expect(config.features.isUsingDefault).toBe(true);
2072
+ });
2073
+
2074
+ it("should be a no-op for non-existent parameter", async () => {
2075
+ const alepha = Alepha.create().with(AlephaOrmPostgres);
2076
+ alepha.with(AlephaApiParameters);
2077
+ await alepha.start();
2078
+
2079
+ const provider = alepha.inject(ParameterProvider);
2080
+
2081
+ // Should not throw
2082
+ await provider.delete("nonexistent.param");
2083
+ });
2084
+ });
2085
+
2086
+ // ---------------------------------------------------------------------------
2087
+ // getHistory pagination
2088
+ // ---------------------------------------------------------------------------
2089
+
2090
+ describe("getHistory pagination", () => {
2091
+ it("should respect limit and offset", async () => {
2092
+ const alepha = Alepha.create().with(AlephaOrmPostgres);
2093
+ alepha.with(AlephaApiParameters);
2094
+ await alepha.start();
2095
+
2096
+ const provider = alepha.inject(ParameterProvider);
2097
+
2098
+ for (let i = 1; i <= 5; i++) {
2099
+ await provider.save("paginate.test", { v: i }, "h");
2100
+ }
2101
+
2102
+ // Get first 2 (versions 5, 4 — desc order)
2103
+ const page1 = await provider.getHistory("paginate.test", {
2104
+ limit: 2,
2105
+ });
2106
+ expect(page1.length).toBe(2);
2107
+ expect(page1[0].version).toBe(5);
2108
+ expect(page1[1].version).toBe(4);
2109
+
2110
+ // Get next 2 (versions 3, 2)
2111
+ const page2 = await provider.getHistory("paginate.test", {
2112
+ limit: 2,
2113
+ offset: 2,
2114
+ });
2115
+ expect(page2.length).toBe(2);
2116
+ expect(page2[0].version).toBe(3);
2117
+ expect(page2[1].version).toBe(2);
2118
+ });
2119
+
2120
+ it("should return all when no limit specified", async () => {
2121
+ const alepha = Alepha.create().with(AlephaOrmPostgres);
2122
+ alepha.with(AlephaApiParameters);
2123
+ await alepha.start();
2124
+
2125
+ const provider = alepha.inject(ParameterProvider);
2126
+
2127
+ await provider.save("paginate.all", { v: 1 }, "h");
2128
+ await provider.save("paginate.all", { v: 2 }, "h");
2129
+ await provider.save("paginate.all", { v: 3 }, "h");
2130
+
2131
+ const all = await provider.getHistory("paginate.all");
2132
+ expect(all.length).toBe(3);
2133
+ });
2134
+ });
2135
+
2136
+ // ---------------------------------------------------------------------------
2137
+ // getVersion on primitive
2138
+ // ---------------------------------------------------------------------------
2139
+
2140
+ describe("$parameter.getVersion", () => {
2141
+ it("should return a specific version via the primitive", async () => {
2142
+ class AppConfig {
2143
+ features = $parameter({
2144
+ name: "prim.getversion",
2145
+ schema: featureSchema,
2146
+ default: { enableBeta: false, maxUploadSize: 10485760 },
2147
+ });
2148
+ }
2149
+
2150
+ const alepha = Alepha.create().with(AlephaOrmPostgres);
2151
+ alepha.with(AlephaApiParameters);
2152
+ alepha.with(AppConfig);
2153
+ await alepha.start();
2154
+
2155
+ const config = alepha.inject(AppConfig);
2156
+
2157
+ await config.features.set({ enableBeta: true, maxUploadSize: 1 });
2158
+ await config.features.set({ enableBeta: false, maxUploadSize: 2 });
2159
+
2160
+ const v1 = await config.features.getVersion(1);
2161
+ expect(v1).not.toBeNull();
2162
+ expect(v1!.content).toEqual({ enableBeta: true, maxUploadSize: 1 });
2163
+
2164
+ const missing = await config.features.getVersion(99);
2165
+ expect(missing).toBeNull();
2166
+ });
2167
+ });
2168
+
2169
+ // ---------------------------------------------------------------------------
2170
+ // Tags
2171
+ // ---------------------------------------------------------------------------
2172
+
2173
+ describe("tags", () => {
2174
+ it("should store and retrieve tags on parameter versions", async () => {
2175
+ class AppConfig {
2176
+ features = $parameter({
2177
+ name: "tags.test",
2178
+ schema: featureSchema,
2179
+ default: { enableBeta: false, maxUploadSize: 10485760 },
2180
+ });
2181
+ }
2182
+
2183
+ const alepha = Alepha.create().with(AlephaOrmPostgres);
2184
+ alepha.with(AlephaApiParameters);
2185
+ alepha.with(AppConfig);
2186
+ await alepha.start();
2187
+
2188
+ const config = alepha.inject(AppConfig);
2189
+
2190
+ await config.features.set(
2191
+ { enableBeta: true, maxUploadSize: 100 },
2192
+ { tags: ["feature-flag", "beta"] },
2193
+ );
2194
+
2195
+ const history = await config.features.getHistory();
2196
+ expect(history[0].tags).toEqual(["feature-flag", "beta"]);
2197
+ });
2198
+ });
2199
+
2200
+ // ---------------------------------------------------------------------------
2201
+ // Set idempotency
2202
+ // ---------------------------------------------------------------------------
2203
+
2204
+ describe("set idempotency", () => {
2205
+ it("should not notify subscribers when setting the same value", async () => {
2206
+ class AppConfig {
2207
+ features = $parameter({
2208
+ name: "idempotent.test",
2209
+ schema: featureSchema,
2210
+ default: { enableBeta: false, maxUploadSize: 10485760 },
2211
+ });
2212
+ }
2213
+
2214
+ const alepha = Alepha.create().with(AlephaOrmPostgres);
2215
+ alepha.with(AlephaApiParameters);
2216
+ alepha.with(AppConfig);
2217
+ await alepha.start();
2218
+
2219
+ const config = alepha.inject(AppConfig);
2220
+
2221
+ await config.features.set({ enableBeta: true, maxUploadSize: 100 });
2222
+
2223
+ const received: unknown[] = [];
2224
+ config.features.sub((v) => received.push(v));
2225
+
2226
+ // Set the exact same value again
2227
+ await config.features.set({ enableBeta: true, maxUploadSize: 100 });
2228
+
2229
+ expect(received.length).toBe(0);
2230
+ });
2231
+ });
@@ -1,6 +1,6 @@
1
1
  import { $inject, AlephaError, t } from "alepha";
2
2
  import { $secure } from "alepha/security";
3
- import { $action } from "alepha/server";
3
+ import { $action, okSchema } from "alepha/server";
4
4
  import { activateParameterBodySchema } from "../schemas/activateParameterBodySchema.ts";
5
5
  import { createParameterVersionBodySchema } from "../schemas/createParameterVersionBodySchema.ts";
6
6
  import { parameterCurrentResponseSchema } from "../schemas/parameterCurrentResponseSchema.ts";
@@ -78,10 +78,17 @@ export class AdminParameterController {
78
78
  method: "GET",
79
79
  schema: {
80
80
  params: parameterNameParamSchema,
81
+ query: t.object({
82
+ limit: t.optional(t.integer({ minimum: 1, maximum: 100 })),
83
+ offset: t.optional(t.integer({ minimum: 0 })),
84
+ }),
81
85
  response: parameterHistoryResponseSchema,
82
86
  },
83
- handler: async ({ params }) => {
84
- const rawVersions = await this.provider.getHistory(params.name);
87
+ handler: async ({ params, query }) => {
88
+ const rawVersions = await this.provider.getHistory(params.name, {
89
+ limit: query.limit,
90
+ offset: query.offset,
91
+ });
85
92
  const versions = this.provider.calculateStatuses(rawVersions);
86
93
  return { versions };
87
94
  },
@@ -241,4 +248,23 @@ export class AdminParameterController {
241
248
  );
242
249
  },
243
250
  });
251
+
252
+ /**
253
+ * Delete all versions of a parameter.
254
+ */
255
+ deleteParameter = $action({
256
+ group: this.group,
257
+ use: [$secure({ permissions: ["admin:parameter:delete"] })],
258
+ description: "Delete all versions of a parameter.",
259
+ path: "/parameters/:name",
260
+ method: "DELETE",
261
+ schema: {
262
+ params: parameterNameParamSchema,
263
+ response: okSchema,
264
+ },
265
+ handler: async ({ params }) => {
266
+ await this.provider.delete(params.name);
267
+ return { ok: true };
268
+ },
269
+ });
244
270
  }
@@ -3,6 +3,18 @@ import { $module } from "alepha";
3
3
  // ---------------------------------------------------------------------------------------------------------------------
4
4
 
5
5
  export * from "./entities/parameters.ts";
6
+ export * from "./schemas/activateParameterBodySchema.ts";
7
+ export * from "./schemas/createParameterVersionBodySchema.ts";
8
+ export * from "./schemas/parameterCurrentResponseSchema.ts";
9
+ export * from "./schemas/parameterHistoryResponseSchema.ts";
10
+ export * from "./schemas/parameterNameParamSchema.ts";
11
+ export * from "./schemas/parameterNamesResponseSchema.ts";
12
+ export * from "./schemas/parameterResponseSchema.ts";
13
+ export * from "./schemas/parameterStatusSchema.ts";
14
+ export * from "./schemas/parameterTreeNodeSchema.ts";
15
+ export * from "./schemas/parameterVersionParamSchema.ts";
16
+ export * from "./schemas/parameterVersionResponseSchema.ts";
17
+ export * from "./schemas/rollbackParameterBodySchema.ts";
6
18
 
7
19
  // ---------------------------------------------------------------------------------------------------------------------
8
20
 
@@ -84,7 +84,10 @@ export class ParameterPrimitive<T extends TObject> extends Primitive<
84
84
  * Parameter name (uses property key if not specified).
85
85
  */
86
86
  public get name(): string {
87
- return this.options.name || this.config.propertyKey;
87
+ return (
88
+ this.options.name ||
89
+ `${this.config.service.name}.${this.config.propertyKey}`
90
+ );
88
91
  }
89
92
 
90
93
  /**
@@ -163,8 +166,22 @@ export class ParameterPrimitive<T extends TObject> extends Primitive<
163
166
  /**
164
167
  * Get version history for this parameter.
165
168
  */
166
- public async getHistory() {
167
- return this.provider.getHistory(this.name);
169
+ public async getHistory(options?: { limit?: number; offset?: number }) {
170
+ return this.provider.getHistory(this.name, options);
171
+ }
172
+
173
+ /**
174
+ * Get a specific version of this parameter.
175
+ */
176
+ public async getVersion(version: number) {
177
+ return this.provider.getVersion(this.name, version);
178
+ }
179
+
180
+ /**
181
+ * Delete all versions of this parameter.
182
+ */
183
+ public async delete(): Promise<void> {
184
+ await this.provider.delete(this.name);
168
185
  }
169
186
 
170
187
  /**
@@ -89,6 +89,12 @@ export class ParameterProvider {
89
89
  */
90
90
  protected readonly loadPromises = new Map<string, Promise<void>>();
91
91
 
92
+ /**
93
+ * Generation counter per parameter — incremented on each doLoad call.
94
+ * Used to discard results from superseded loads.
95
+ */
96
+ protected readonly loadGeneration = new Map<string, number>();
97
+
92
98
  /**
93
99
  * Subscriber callbacks per parameter name.
94
100
  */
@@ -379,6 +385,15 @@ export class ParameterProvider {
379
385
  schemaHash = this.schemaHashes.get(name) ?? "";
380
386
  }
381
387
 
388
+ // Validate content against the registered schema when the schema hash
389
+ // matches. A mismatched hash means the content is from a different
390
+ // schema version (e.g., migration seed) and should not be validated
391
+ // against the current schema.
392
+ const param = this.primitives.get(name);
393
+ if (param && schemaHash === this.schemaHashes.get(name)) {
394
+ content = this.alepha.codec.validate(param.schema, content) as Static<T>;
395
+ }
396
+
382
397
  const now = this.dateTimeProvider.now().toDate();
383
398
  const activationDate = options.activationDate ?? now;
384
399
  const isImmediate = activationDate <= now;
@@ -444,13 +459,32 @@ export class ParameterProvider {
444
459
  /**
445
460
  * Get all versions of a parameter.
446
461
  */
447
- public async getHistory(name: string): Promise<Parameter[]> {
462
+ public async getHistory(
463
+ name: string,
464
+ options?: { limit?: number; offset?: number },
465
+ ): Promise<Parameter[]> {
448
466
  return this.repo.findMany({
449
467
  where: { name },
450
468
  orderBy: { column: "version", direction: "desc" },
469
+ limit: options?.limit,
470
+ offset: options?.offset,
451
471
  });
452
472
  }
453
473
 
474
+ /**
475
+ * Delete all versions of a parameter.
476
+ */
477
+ public async delete(name: string): Promise<void> {
478
+ await this.repo.deleteMany({ name: { eq: name } });
479
+ this.cachedCurrent.delete(name);
480
+ this.cachedNext.delete(name);
481
+ this.loaded.delete(name);
482
+ this.loadPromises.delete(name);
483
+ this.loadGeneration.delete(name);
484
+ this.migrationChecked.delete(name);
485
+ this.log.info("Parameter deleted", { name });
486
+ }
487
+
454
488
  /**
455
489
  * Get a specific version of a parameter.
456
490
  */
@@ -568,9 +602,15 @@ export class ParameterProvider {
568
602
  * Fetches current and next from database, updates cache.
569
603
  */
570
604
  protected async doLoad(name: string): Promise<void> {
605
+ const gen = (this.loadGeneration.get(name) ?? 0) + 1;
606
+ this.loadGeneration.set(name, gen);
607
+
571
608
  const { current, next } = await this.loadCurrentAndNext(name);
572
609
  const schemaHash = this.schemaHashes.get(name) ?? "";
573
610
 
611
+ // Superseded by a newer load — discard results
612
+ if (this.loadGeneration.get(name) !== gen) return;
613
+
574
614
  // Check if migration is needed
575
615
  if (current && !this.migrationChecked.has(name)) {
576
616
  this.migrationChecked.add(name);
@@ -665,21 +705,22 @@ export class ParameterProvider {
665
705
  }
666
706
 
667
707
  /**
668
- * Poll until a lock is released.
708
+ * Poll until a lock is released (or TTL expires).
709
+ * Uses a probe-only SET NX with minimal TTL to detect release
710
+ * without holding the lock longer than necessary.
669
711
  */
670
712
  protected async waitForLock(lockKey: string): Promise<void> {
671
713
  const maxWait = 30_000;
714
+ const probeId = crypto.randomUUID();
672
715
  const start = this.dateTimeProvider.nowMillis();
673
716
  while (this.dateTimeProvider.nowMillis() - start < maxWait) {
674
- await this.dateTimeProvider.wait(200);
675
- const lockId = crypto.randomUUID();
676
- const value = await this.lockProvider.set(lockKey, lockId, true, 1000);
677
- if (value === lockId) {
717
+ await this.dateTimeProvider.wait(500);
718
+ const value = await this.lockProvider.set(lockKey, probeId, true, 500);
719
+ if (value === probeId) {
678
720
  await this.lockProvider.del(lockKey);
679
721
  return;
680
722
  }
681
723
  }
682
- // Timeout — proceed anyway (lock expired or will expire)
683
724
  }
684
725
 
685
726
  /**