@getstrata/core 2.2.13 → 2.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,8 +1,28 @@
1
1
  # @getstrata/core changelog
2
2
 
3
+ ## 2.2.15
4
+
5
+ - Batch legacy list-queue priority polling while retaining recovery and ownership contracts.
6
+ - Batch dialect-portable pivot writes with framework transaction/savepoint composition and consistent numeric/string ID toggle semantics. Noncanonical or textual IDs use sequential database comparisons to preserve coercion and collation behavior.
7
+ - New Redis scaffolds explicitly select Streams; existing unset transport settings continue to select lists.
8
+ - Generate the SQL outbox migration, listener registry and managed worker command. Durable publication and cache-invalidation activation remain explicit.
9
+
10
+ - Primary-key ascending `chunk` queries reuse the existing keyset walk, including default primary-key ordering; projections, joins, grouping, locks and other orders retain OFFSET behavior.
11
+ - Empty filtered pagination totals skip the row query. Nonempty page-number pagination preserves OFFSET semantics and its count-plus-row-query budget; explicit cursor/keyset APIs remain available for deep traversal.
12
+ - Independent hashed write casts run concurrently. Deterministic regressions prove concurrent start, complete awaiting and failure before model observers or SQL writes; hydration and existing-hash behavior are preserved.
13
+ - Streams workers poll retry promotion, pending recovery and new delivery across priorities in one bounded Redis script, preserving priority, ownership fencing and at-least-once delivery contracts.
14
+ - Clarify historical 2.1.0–2.2.13 release notes and database/queue documentation. Remove the consolidated 1.x-to-2.x migration guide; existing projects are upgraded individually.
15
+
16
+ ## 2.2.14
17
+
18
+ Complete the scoped HTML rendering starter adoption: generated apps inject ViewRenderer through createViewProvider instead of copying renderPage; welcome pages opt into HTMX fragments with full-page fallbacks, auth retains existing contracts, and CORS preserves representation Vary headers. Includes generator, runtime auth and cache-header regressions.
19
+
20
+ Add optional typed resourceRoutes and strict mergeRouteMaps composition, preserving native Bun routing and app-owned middleware. Add explicit ViewRenderer.namespace prefixes with unchanged layout/Response/HTMX contracts; generated auth controllers use the namespace. Includes native HTTP, compile-time, rendering isolation and generator regressions.
21
+
3
22
  ## 2.2.13
4
23
 
5
- - Improve view rendering and better htmx support
24
+ - Add a typed per-app HTML renderer on top of the native response path.
25
+ - Add explicit HTMX page and fragment responses. A fragment can fall back to the full page. Layout and auth response contracts stay the same.
6
26
 
7
27
  ## 2.2.12
8
28
 
@@ -24,42 +44,47 @@ Add independently interruptible read-only SQLite and MySQL observations; extend
24
44
 
25
45
  ## 2.2.9
26
46
 
27
- - Support native cancellable transaction acquisition
47
+ - Pool checkout can be cancelled. `reserve({ signal })` on the native driver aborts while it is still waiting for a connection. Drivers without that method are not given a fake one.
28
48
 
29
49
  ## 2.2.8
30
50
 
31
- - Add bounded read-only Postgres outbox metrics
51
+ - Add bounded, read-only Postgres outbox metrics.
52
+ - Add bounded, opt-in Redis queue metrics. List and Streams depth stay separate.
32
53
 
33
54
  ## 2.2.7
34
55
 
35
- - Where an ordinary operation needs SQL because the framework cannot express it
56
+ - `ORDER BY` accepts explicit `nulls: "first" | "last"`. Postgres and SQLite emit `NULLS FIRST` / `NULLS LAST`. MySQL has no `NULLS FIRST` / `NULLS LAST` clause, so it orders nulls with an `IS NULL` discriminator and leaves the rest of the sort unchanged.
36
57
 
37
58
  ## 2.2.6
38
59
 
39
- - fix(cli): relay shutdown to children and simplify container launcher
60
+ - The CLI, when it supervises the app or worker as a child, forwards shutdown signals to that child and waits for it. The container launcher no longer exits while the child is still running.
40
61
 
41
62
  ## 2.2.5
42
63
 
43
- - fix(starter): keep container readiness independent of API admission
64
+ - Container readiness no longer means the API is admitting traffic. Database readiness no longer provisions roles or creates a missing database. A failed configured admin connection does not fall back to a generated password.
44
65
 
45
66
  ## 2.2.4
46
67
 
47
- - perf(database): seek Postgres composite keyset bounds
68
+ - Composite keyset pages on Postgres seek with a row comparison (`(a, b) > (cursor)`), so the planner can start at the index bound instead of expanding the cursor into a chain of OR predicates.
48
69
 
49
70
  ## 2.2.3
50
71
 
51
- - feat(database): add scoped composite keyset pagination
72
+ - Add scoped composite keyset pagination. The order must end with the primary key. Global scopes and caller filters stay in force across the cursor. Joins, grouping, and offset queries are rejected.
52
73
 
53
74
  ## 2.2.2
54
75
 
55
- - orm improvments
76
+ - Schema `bigId` emits an explicit 64-bit generated identity.
77
+ - Repository writes can be conditional and set-based.
78
+ - Repositories and models can take a typed row lock inside the current transaction.
79
+ - Generated Postgres query handles keep tagged-SQL typing.
56
80
 
57
81
  ## 2.2.1
58
82
 
59
- F02 — complete queue durability. Queues have leased reservations, recovery and fenced acknowledgement, but still use Redis lists. Persisted retry schedules, execution deadlines, cancellation and the planned Streams migration remain unfinished. Retries currently wait inside the running worker.
60
- F10 — production public-route tenancy. Separate anonymous access from development tenant headers, support a trusted-host resolver, reject unknown hosts, and accept forwarded hosts only through configured trusted proxies. The shop still lacks the approved-domain mapping.
61
- F12 — initial administrator provisioning and migration guidance. Automatic demo seeding is fixed. Secure initial-admin provisioning and a consolidated upgrade/operations guide remain.
62
- Distributed qualification. Cache generations/fill leases, scheduler ownership, OpenTelemetry, body limits and lifecycle controls now exist. Their implementation should no longer be described as entirely pending, but broader failure and capacity qualification is still required.
83
+ - Opt-in Redis Streams (`QUEUE_REDIS_TRANSPORT=streams`). Lists stay the default. Streams adds consumer-group recovery, persisted retry schedules, total job deadlines, opt-in cancellation, and a stable job id across replay. Conversion from lists is a stopped maintenance step.
84
+ - Failed jobs stay recorded until retry admission succeeds. Manual replay keeps `failed_job.job_id` when the row has one.
85
+ - Public reads and tenant host selection are separate. Unknown hosts fail closed. Forwarded hosts are accepted only from configured trusted proxies.
86
+ - Generated apps can provision one initial administrator with `auth:provision-admin` and a one-time claim migration. Demo seeding stays explicit and is rejected in production and staging.
87
+ - Quota and in-memory throttle docs match the code: memory throttles stay process-local, and a Redis failure does not fall back to them.
63
88
 
64
89
  ## 2.2.0
65
90
 
@@ -96,7 +121,9 @@ Bounded memory throttling.
96
121
 
97
122
  ## 2.1.0
98
123
 
99
- - refactor(starter): reuse shared config and base repositories - fix(database): await password casts before model writes - fix(database): preserve concrete model types in query results
124
+ - Generated apps reuse the shared config store and base repositories instead of a copied stack.
125
+ - Password casts are awaited before the write reaches SQL or observers. Hydration does not hash.
126
+ - Model queries keep the concrete model type in their results. Service tokens, route handlers, and the starter's generated infrastructure were tightened in the same release.
100
127
 
101
128
  ## 2.0.9
102
129
 
@@ -69,6 +69,7 @@ declare class BaseRepository<TEntity extends object, PrimaryKey extends keyof TE
69
69
  softDeleteById(id: TEntity[PrimaryKey]): Promise<boolean>;
70
70
  forceDeleteById(id: TEntity[PrimaryKey]): Promise<boolean>;
71
71
  restoreById(id: TEntity[PrimaryKey]): Promise<TEntity | null>;
72
+ private primaryKeySeekDirection;
72
73
  withConnection(connection: DatabaseConnection): this;
73
74
  getConnection(): DatabaseConnection;
74
75
  getTable(): TableDefinition<TEntity, PrimaryKey>;
@@ -2,5 +2,6 @@ import type { SqlDatabaseConnection } from "./baseRepository";
2
2
  declare function registerDefaultDatabasePool(connection: SqlDatabaseConnection): void;
3
3
  declare function getDefaultDatabasePool(): SqlDatabaseConnection;
4
4
  declare function getDefaultDatabaseQuery(): SqlDatabaseConnection;
5
+ declare function isDefaultDatabaseConnection(connection: unknown): boolean;
5
6
  declare function resetDefaultDatabasePoolForTests(): void;
6
- export { getDefaultDatabasePool, getDefaultDatabaseQuery, registerDefaultDatabasePool, resetDefaultDatabasePoolForTests, };
7
+ export { getDefaultDatabasePool, getDefaultDatabaseQuery, isDefaultDatabaseConnection, registerDefaultDatabasePool, resetDefaultDatabasePoolForTests, };
@@ -112,6 +112,7 @@ declare class BelongsToManyRelationQuery<TParent extends object, ParentKey exten
112
112
  hydrateEager(row: Record<string, unknown>, alias: string): unknown;
113
113
  toExistsClause(parentTable: string): ExistsClause;
114
114
  private connection;
115
+ private atomically;
115
116
  get(): Promise<RelatedRecord[]>;
116
117
  first(): Promise<RelatedRecord | null>;
117
118
  count(): Promise<number>;
@@ -123,6 +124,9 @@ declare class BelongsToManyRelationQuery<TParent extends object, ParentKey exten
123
124
  attach(ids: unknown | readonly unknown[]): Promise<void>;
124
125
  toggle(ids: unknown | readonly unknown[]): Promise<void>;
125
126
  detach(ids?: unknown | readonly unknown[]): Promise<void>;
127
+ private toggleOne;
128
+ private insertPivotRows;
129
+ private deletePivotRows;
126
130
  sync(ids: readonly unknown[]): Promise<void>;
127
131
  create(attributes?: Record<string, unknown>): Promise<RelatedRecord>;
128
132
  }
@@ -14,8 +14,8 @@ export { parseMultipartUpload, sanitizeUploadFileName, validateUploadFile, } fro
14
14
  export { createRequireAuthMiddleware } from "./requireAuthMiddleware";
15
15
  export { serializeDate, toPaginatedResourceCollection, toResourceCollection } from "./resources";
16
16
  export { createdResponse, errorResponse, jsonResponse, noContentResponse, withErrorHandling, } from "./response";
17
- export type { RouteRequest } from "./route";
18
- export { getRouteParams } from "./route";
17
+ export type { ResourceActions, ResourceMemberHandler, ResourceRouteMap, RouteRequest, } from "./route";
18
+ export { getRouteParams, mergeRouteMaps, resourceRoutes } from "./route";
19
19
  export { withMiddleware } from "./routeMiddleware";
20
20
  export { bindRouteModel } from "./routeModelBinding";
21
21
  export { securedBindRouteModel, securedBindRouteModelByKey, } from "./securedRouteModelBinding";
@@ -1,3 +1,67 @@
1
+ import type { RouteHandler } from "./middleware";
2
+ type ResourceMemberHandler = RouteHandler<RouteRequest<{
3
+ id: string;
4
+ }>>;
5
+ interface ResourceActions {
6
+ index?: RouteHandler;
7
+ create?: RouteHandler;
8
+ store?: RouteHandler;
9
+ show?: ResourceMemberHandler;
10
+ edit?: ResourceMemberHandler;
11
+ update?: ResourceMemberHandler;
12
+ destroy?: ResourceMemberHandler;
13
+ }
14
+ type ResourceRouteMap<P extends string, A extends ResourceActions> = (A extends {
15
+ index: infer H;
16
+ } ? {
17
+ [K in P]: {
18
+ GET: H;
19
+ };
20
+ } : Record<never, never>) & (A extends {
21
+ store: infer H;
22
+ } ? {
23
+ [K in P]: {
24
+ POST: H;
25
+ };
26
+ } : Record<never, never>) & (A extends {
27
+ create: infer H;
28
+ } ? {
29
+ [K in `${P}/create`]: {
30
+ GET: H;
31
+ };
32
+ } : Record<never, never>) & (A extends {
33
+ show: infer H;
34
+ } ? {
35
+ [K in `${P}/:id`]: {
36
+ GET: H;
37
+ };
38
+ } : Record<never, never>) & (A extends {
39
+ edit: infer H;
40
+ } ? {
41
+ [K in `${P}/:id/edit`]: {
42
+ GET: H;
43
+ };
44
+ } : Record<never, never>) & (A extends {
45
+ update: infer H;
46
+ } ? {
47
+ [K in `${P}/:id`]: {
48
+ PUT: H;
49
+ PATCH: H;
50
+ };
51
+ } : Record<never, never>) & (A extends {
52
+ destroy: infer H;
53
+ } ? {
54
+ [K in `${P}/:id`]: {
55
+ DELETE: H;
56
+ };
57
+ } : Record<never, never>);
58
+ /** Expand only explicitly supplied actions into native Bun routes. */
59
+ declare function resourceRoutes<const P extends string, const A extends ResourceActions>(path: P, actions: A & Record<Exclude<keyof A, keyof ResourceActions>, never>): ResourceRouteMap<P, A>;
60
+ type Intersection<U> = (U extends unknown ? (value: U) => void : never) extends (value: infer I) => void ? I : never;
61
+ /** Strict composition: duplicate paths fail rather than silently replacing handlers. */
62
+ declare function mergeRouteMaps<const T extends readonly Record<string, unknown>[]>(...maps: T): Intersection<T[number]>;
63
+ export type { ResourceActions, ResourceMemberHandler, ResourceRouteMap };
64
+ export { mergeRouteMaps, resourceRoutes };
1
65
  type RouteRequest<TParams extends Record<string, string>> = Request & {
2
66
  params: TParams;
3
67
  };
@@ -15,6 +15,10 @@ type QueueReservation = {
15
15
  declare function queueInvalidKey(queueKey: string): string;
16
16
  declare function reserveQueueJob(client: RedisClient, queueKey: string, owner: string): Promise<QueueReservation | null>;
17
17
  declare function updateQueueReservation(client: RedisClient, queueKey: string, reservation: QueueReservation, action: "renew" | "ack" | "quarantine"): Promise<boolean>;
18
+ declare function pollListPriorities(client: RedisClient, queueKeys: readonly string[], owner: string, reservationId: string, reclaim: boolean): Promise<{
19
+ queueKey: string;
20
+ reservation: QueueReservation;
21
+ } | null>;
18
22
  declare function reclaimExpiredQueueReservations(client: RedisClient, queueKeys?: readonly string[]): Promise<number>;
19
23
  declare class RedisQueue implements Queue {
20
24
  private readonly client;
@@ -42,4 +46,4 @@ declare function countPendingQueueJobs(redisUrl: string): Promise<number>;
42
46
  export type { StreamReservation, StreamsEnqueueOptions } from "./redisStreams";
43
47
  export { migrateLegacyQueueToStreams, RedisStreamsQueue, RedisStreamsWorker, STREAM_GROUP, streamDeadLetterKey, streamQueueKey, updateStreamReservation, } from "./redisStreams";
44
48
  export type { QueueJobEnvelope, QueueReservation };
45
- export { countPendingQueueJobs, parseQueueJobEnvelope, QUEUE_HIGH_KEY, QUEUE_LIST_KEY, QUEUE_LOW_KEY, QueueWorker, queueInvalidKey, queueKeyForPriority, queueProcessingKey, queueProcessingLeaseKey, RedisQueue, reclaimExpiredQueueReservations, reservationExpired, reserveQueueJob, updateQueueReservation, };
49
+ export { countPendingQueueJobs, parseQueueJobEnvelope, pollListPriorities, QUEUE_HIGH_KEY, QUEUE_LIST_KEY, QUEUE_LOW_KEY, QueueWorker, queueInvalidKey, queueKeyForPriority, queueProcessingKey, queueProcessingLeaseKey, RedisQueue, reclaimExpiredQueueReservations, reservationExpired, reserveQueueJob, updateQueueReservation, };
@@ -18,6 +18,13 @@ declare function readStreamTime(client: RedisClient): Promise<number>;
18
18
  declare function watchStreamDeadline(client: RedisClient, deadlineAtMs: number, remainingMs: number, expire: () => void, fail: (error: unknown) => void): () => void;
19
19
  declare function scheduleStreamRetry(client: RedisClient, key: string, reservation: StreamReservation, envelope: QueueJobEnvelope, delayMs: number): Promise<void>;
20
20
  declare function promoteStreamRetries(client: RedisClient, key: string): Promise<number>;
21
+ declare function pollStreamPriorities(client: RedisClient, queueKeys: readonly string[], owner: string, cursors: readonly string[]): Promise<{
22
+ cursors: string[];
23
+ hit: {
24
+ index: number;
25
+ reservation: StreamReservation;
26
+ } | null;
27
+ }>;
21
28
  declare function readStreamQueueDepth(client: RedisClient, key: string): Promise<number>;
22
29
  type StreamReservation = {
23
30
  id: string;
@@ -58,7 +65,6 @@ declare class RedisStreamsWorker {
58
65
  isRunning(): boolean;
59
66
  close(): void;
60
67
  private initialize;
61
- private reserve;
62
68
  processNext(): Promise<boolean>;
63
69
  private processOne;
64
70
  private handle;
@@ -70,4 +76,4 @@ declare function migrateLegacyQueueToStreams(client: RedisClient, options: {
70
76
  queueKeys?: readonly string[];
71
77
  }): Promise<number>;
72
78
  export type { StreamReservation, StreamsEnqueueOptions };
73
- export { cancelStreamJob, ensureStreamGroup, migrateLegacyQueueToStreams, promoteStreamRetries, RedisStreamsQueue, RedisStreamsWorker, readStreamCancellation, readStreamEntry, readStreamQueueDepth, readStreamTime, reserveStreamJob, STREAM_GROUP, scheduleStreamRetry, streamControlKey, streamDeadLetterKey, streamQueueKey, streamRetryKey, updateStreamReservation, watchStreamDeadline, };
79
+ export { cancelStreamJob, ensureStreamGroup, migrateLegacyQueueToStreams, pollStreamPriorities, promoteStreamRetries, RedisStreamsQueue, RedisStreamsWorker, readStreamCancellation, readStreamEntry, readStreamQueueDepth, readStreamTime, reserveStreamJob, STREAM_GROUP, scheduleStreamRetry, streamControlKey, streamDeadLetterKey, streamQueueKey, streamRetryKey, updateStreamReservation, watchStreamDeadline, };
@@ -2,7 +2,7 @@ export type { LayoutDataResolver, RenderOptions } from "./etaViewEngine";
2
2
  export { DEFAULT_VIEWS_DIRECTORY, EtaViewEngine } from "./etaViewEngine";
3
3
  export { htmlResponse, isHtmxRequest, redirectResponse, rssResponse, textResponse, xmlResponse, } from "./htmlResponse";
4
4
  export type { ViewEngine } from "./viewEngine";
5
- export type { HtmxViewResponseOptions, ViewResponseOptions } from "./viewRenderer";
5
+ export type { HtmxViewResponseOptions, NamespacedViewRenderer, ViewResponseOptions, } from "./viewRenderer";
6
6
  export { CORE_VIEW_RENDERER_TOKEN, ViewRenderer } from "./viewRenderer";
7
7
  export type { WebErrorViewInput, WebErrorViewOptions } from "./webErrorView";
8
8
  export { configureWebErrorView, errorTemplateName, htmlErrorResponse, notFoundHtmlResponse, renderKernelErrorChrome, renderWebErrorHtml, } from "./webErrorView";
@@ -10,15 +10,21 @@ interface HtmxViewResponseOptions extends ViewResponseOptions {
10
10
  /** Optional fragment template; otherwise render the page template without its layout. */
11
11
  fragment?: string;
12
12
  }
13
+ interface NamespacedViewRenderer {
14
+ render: ViewRenderer["render"];
15
+ renderHtmx: ViewRenderer["renderHtmx"];
16
+ }
13
17
  /** App-owned renderer; request data stays in the engine's async resolver. */
14
18
  declare class ViewRenderer {
15
19
  private readonly engine;
16
20
  private readonly defaultLayout?;
17
21
  constructor(engine: ViewEngine, defaultLayout?: string | false | undefined);
22
+ /** Explicit template prefix; layout names and response contracts remain unchanged. */
23
+ namespace(prefix: string): NamespacedViewRenderer;
18
24
  render(name: string, data?: Record<string, unknown>, options?: ViewResponseOptions): Promise<Response>;
19
25
  /** Opt-in representation selection; auth/CSRF/tenancy remain the route's responsibility. */
20
26
  renderHtmx(name: string, data?: Record<string, unknown>, options?: HtmxViewResponseOptions): Promise<Response>;
21
27
  }
22
28
  declare const CORE_VIEW_RENDERER_TOKEN: import("../contracts/container").ServiceToken<ViewRenderer>;
23
- export type { HtmxViewResponseOptions, ViewResponseOptions };
29
+ export type { HtmxViewResponseOptions, NamespacedViewRenderer, ViewResponseOptions };
24
30
  export { CORE_VIEW_RENDERER_TOKEN, ViewRenderer };
@@ -141,7 +141,16 @@ function createCorsMiddleware(options = {}) {
141
141
  const response = await next();
142
142
  const headers = new Headers(response.headers);
143
143
  for (const [key, value] of buildCorsHeaders(request, captured)) {
144
- headers.set(key, value);
144
+ if (key === "vary") {
145
+ const fields = (headers.get("Vary") ?? "").split(",").map((field) => field.trim()).filter(Boolean);
146
+ if (fields.includes("*"))
147
+ continue;
148
+ if (!fields.some((field) => field.toLowerCase() === "origin"))
149
+ fields.push(value);
150
+ headers.set("Vary", fields.join(", "));
151
+ } else {
152
+ headers.set(key, value);
153
+ }
145
154
  }
146
155
  return new Response(response.body, {
147
156
  status: response.status,
@@ -1,8 +1,52 @@
1
1
  // @bun
2
2
  // ../../src/core/http/route.ts
3
+ function resourceRoutes(path, actions) {
4
+ if (!/^\/[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*$/.test(path) || path.split("/").some((part) => part === "." || part === "..")) {
5
+ throw new TypeError("Resource path must be a static absolute path without a trailing slash.");
6
+ }
7
+ const routes = {};
8
+ const definitions = {
9
+ index: [path, ["GET"]],
10
+ create: [`${path}/create`, ["GET"]],
11
+ store: [path, ["POST"]],
12
+ show: [`${path}/:id`, ["GET"]],
13
+ edit: [`${path}/:id/edit`, ["GET"]],
14
+ update: [`${path}/:id`, ["PUT", "PATCH"]],
15
+ destroy: [`${path}/:id`, ["DELETE"]]
16
+ };
17
+ for (const [action, handler] of Object.entries(actions)) {
18
+ if (!Object.hasOwn(definitions, action) || typeof handler !== "function") {
19
+ throw new TypeError(`Invalid resource action: ${action}`);
20
+ }
21
+ const [route, methods] = definitions[action];
22
+ const methodMap = routes[route] ?? {};
23
+ routes[route] = methodMap;
24
+ for (const method of methods)
25
+ methodMap[method] = handler;
26
+ }
27
+ return routes;
28
+ }
29
+ function mergeRouteMaps(...maps) {
30
+ const routes = {};
31
+ for (const map of maps) {
32
+ for (const [path, handler] of Object.entries(map)) {
33
+ if (Object.hasOwn(routes, path))
34
+ throw new Error(`Duplicate route path: ${path}`);
35
+ Object.defineProperty(routes, path, {
36
+ value: handler,
37
+ enumerable: true,
38
+ writable: true,
39
+ configurable: true
40
+ });
41
+ }
42
+ }
43
+ return routes;
44
+ }
3
45
  function getRouteParams(request) {
4
46
  return request.params;
5
47
  }
6
48
  export {
7
- getRouteParams
49
+ getRouteParams,
50
+ mergeRouteMaps,
51
+ resourceRoutes
8
52
  };