@ultimat3/cli 29.3.1 → 29.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "29.3.1",
3
+ "version": "29.4.0",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -39,33 +39,33 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@babel/core": "^8.0.7",
42
- "@ultimat3/action": "29.3.1",
43
- "@ultimat3/admin": "29.3.1",
44
- "@ultimat3/ai": "29.3.1",
45
- "@ultimat3/auth": "29.3.1",
46
- "@ultimat3/cache": "29.3.1",
47
- "@ultimat3/core": "29.3.1",
48
- "@ultimat3/db": "29.3.1",
49
- "@ultimat3/entity": "29.3.1",
50
- "@ultimat3/flags": "29.3.1",
51
- "@ultimat3/http": "29.3.1",
52
- "@ultimat3/i18n": "29.3.1",
53
- "@ultimat3/jobs": "29.3.1",
54
- "@ultimat3/mail": "29.3.1",
55
- "@ultimat3/manifest": "29.3.1",
56
- "@ultimat3/mcp": "29.3.1",
57
- "@ultimat3/money": "29.3.1",
58
- "@ultimat3/notify": "29.3.1",
59
- "@ultimat3/policy": "29.3.1",
60
- "@ultimat3/pwa": "29.3.1",
61
- "@ultimat3/query": "29.3.1",
62
- "@ultimat3/realtime": "29.3.1",
63
- "@ultimat3/render": "29.3.1",
64
- "@ultimat3/schema": "29.3.1",
65
- "@ultimat3/seo": "29.3.1",
66
- "@ultimat3/storage": "29.3.1",
67
- "@ultimat3/testing": "29.3.1",
68
- "@ultimat3/time": "29.3.1",
42
+ "@ultimat3/action": "29.4.0",
43
+ "@ultimat3/admin": "29.4.0",
44
+ "@ultimat3/ai": "29.4.0",
45
+ "@ultimat3/auth": "29.4.0",
46
+ "@ultimat3/cache": "29.4.0",
47
+ "@ultimat3/core": "29.4.0",
48
+ "@ultimat3/db": "29.4.0",
49
+ "@ultimat3/entity": "29.4.0",
50
+ "@ultimat3/flags": "29.4.0",
51
+ "@ultimat3/http": "29.4.0",
52
+ "@ultimat3/i18n": "29.4.0",
53
+ "@ultimat3/jobs": "29.4.0",
54
+ "@ultimat3/mail": "29.4.0",
55
+ "@ultimat3/manifest": "29.4.0",
56
+ "@ultimat3/mcp": "29.4.0",
57
+ "@ultimat3/money": "29.4.0",
58
+ "@ultimat3/notify": "29.4.0",
59
+ "@ultimat3/policy": "29.4.0",
60
+ "@ultimat3/pwa": "29.4.0",
61
+ "@ultimat3/query": "29.4.0",
62
+ "@ultimat3/realtime": "29.4.0",
63
+ "@ultimat3/render": "29.4.0",
64
+ "@ultimat3/schema": "29.4.0",
65
+ "@ultimat3/seo": "29.4.0",
66
+ "@ultimat3/storage": "29.4.0",
67
+ "@ultimat3/testing": "29.4.0",
68
+ "@ultimat3/time": "29.4.0",
69
69
  "babel-preset-solid": "^1.9.16"
70
70
  }
71
71
  }
package/src/cmd-db.ts CHANGED
@@ -166,6 +166,8 @@ async function runGen(ctx: CommandContext, root: string, name: string): Promise<
166
166
  data: {
167
167
  outcome: generated.outcome,
168
168
  migration: migration.id,
169
+ // The `-- transaction: false` step written beside it, when the diff needed one.
170
+ followUp: migration.followUp?.id ?? null,
169
171
  name: migration.name,
170
172
  files: [...generated.files],
171
173
  schemaHash: generated.schemaHash ?? null,
@@ -155,20 +155,31 @@ export async function generateAppMigration(
155
155
  };
156
156
  }
157
157
 
158
+ // One migration, or two: a diff that also builds or drops indexes concurrently carries that step
159
+ // as `followUp` — its own `.sql` marked `-- transaction: false`, its own sidecars, sorting right
160
+ // after this one. Each file is a ledger row of its own; see `@ultimat3/db`'s `generate-steps.ts`.
161
+ const steps = [migration, ...(migration.followUp === undefined ? [] : [migration.followUp])];
158
162
  const dir = join(root, MIGRATIONS_DIR);
159
- const sql = `${migration.id}.sql`;
160
- const snapshot = snapshotFileName(migration.id);
161
- await Bun.write(join(dir, sql), migrationSql(migration));
162
- // `snapshotJson`, never `JSON.stringify`: the sidecar is the one migration artefact a scaffolded
163
- // app's `biome check .` reads, and its bytes carry their own trailing newline.
164
- await Bun.write(join(dir, snapshot), snapshotJson(migration.snapshot));
165
- const schemaHash = await writeSchemaHash(root, migration.id);
163
+ const files: string[] = [];
164
+ let schemaHash = '';
165
+ for (const step of steps) {
166
+ const sql = `${step.id}.sql`;
167
+ const snapshot = snapshotFileName(step.id);
168
+ await Bun.write(join(dir, sql), migrationSql(step));
169
+ // `snapshotJson`, never `JSON.stringify`: the sidecar is the one migration artefact a
170
+ // scaffolded app's `biome check .` reads, and its bytes carry their own trailing newline.
171
+ await Bun.write(join(dir, snapshot), snapshotJson(step.snapshot));
172
+ schemaHash = await writeSchemaHash(root, step.id);
173
+ files.push(
174
+ ...[sql, snapshot, hashFileName(step.id)].map((file) => `${MIGRATIONS_DIR}/${file}`),
175
+ );
176
+ }
166
177
 
167
178
  return {
168
179
  outcome: 'generated',
169
180
  migration,
170
181
  schemaHash,
171
- files: [sql, snapshot, hashFileName(migration.id)].map((file) => `${MIGRATIONS_DIR}/${file}`),
182
+ files,
172
183
  findings: [],
173
184
  unrendered: migration.unrendered,
174
185
  };
@@ -10,7 +10,14 @@
10
10
  import type { Actor } from '@ultimat3/core';
11
11
  import { actorOf } from '@ultimat3/core';
12
12
  import type { CacheHint, RequestContext, Route, UltimateRequest } from '@ultimat3/http';
13
- import { asCtx, unauthenticated } from '@ultimat3/http';
13
+ import {
14
+ asCtx,
15
+ byteWindow,
16
+ contentRangeOf,
17
+ parseByteRange,
18
+ RANGE_UNSATISFIABLE,
19
+ unauthenticated,
20
+ } from '@ultimat3/http';
14
21
  import type { KnownPermission } from '@ultimat3/policy';
15
22
  import {
16
23
  can,
@@ -174,46 +181,6 @@ export async function readStorageObject(
174
181
  return { object, open: () => driver.stream(key) };
175
182
  }
176
183
 
177
- /** Inclusive, as `Range` and `Content-Range` both are. */
178
- export interface ByteRange {
179
- readonly start: number;
180
- readonly end: number;
181
- }
182
-
183
- const UNSATISFIABLE = 'unsatisfiable';
184
-
185
- /**
186
- * One range or none. A multi-range or malformed header is ignored rather than refused — RFC 9110
187
- * lets a server answer the whole representation, and `multipart/byteranges` is a body format no
188
- * caller of this route asks for. Ranges exist here for one reason: Safari will not play a `<video>`
189
- * from a source that answers 200 to a `Range` probe, so an uploaded video would be a dead player.
190
- */
191
- export function parseByteRange(
192
- header: string | null,
193
- size: number,
194
- ): ByteRange | typeof UNSATISFIABLE | undefined {
195
- if (header === null) return undefined;
196
- const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
197
- if (match === null) return undefined;
198
- const from = match[1] ?? '';
199
- const to = match[2] ?? '';
200
- if (from === '' && to === '') return undefined;
201
- if (from === '') {
202
- const wanted = Number(to);
203
- // A suffix longer than the object is the whole object, not a refusal — but there is no whole
204
- // object to fall back to at `size === 0`, and the arithmetic below answers `{ start: 0, end:
205
- // -1 }`, which the route renders as `content-range: bytes 0--1/0` with status 206. RFC 9110
206
- // requires 416. The non-suffix branch already gets this right through `start >= size`; this
207
- // one had no equivalent test.
208
- return size === 0 || wanted === 0
209
- ? UNSATISFIABLE
210
- : { start: Math.max(size - wanted, 0), end: size - 1 };
211
- }
212
- const start = Number(from);
213
- const end = to === '' ? size - 1 : Math.min(Number(to), size - 1);
214
- return start >= size || end < start ? UNSATISFIABLE : { start, end };
215
- }
216
-
217
184
  /** `*`, a weak validator and a comma list all count — the etag is what has to match. */
218
185
  export function etagMatches(header: string | null, etag: string): boolean {
219
186
  if (header === null) return false;
@@ -221,31 +188,6 @@ export function etagMatches(header: string | null, etag: string): boolean {
221
188
  return header.split(',').some((candidate) => candidate.trim().replace(/^W\//, '') === etag);
222
189
  }
223
190
 
224
- /**
225
- * The inclusive window `[start, end]` of a stream, as a stream. A disk streams from byte 0 (the
226
- * driver contract has no ranged read), so the bytes before `start` are read and dropped: bounded
227
- * memory, at the cost of reading the prefix — the trade a `Range` probe on a video wants over
228
- * buffering the whole object.
229
- */
230
- function windowOf(
231
- source: ReadableStream<Uint8Array>,
232
- start: number,
233
- end: number,
234
- ): ReadableStream<Uint8Array> {
235
- let offset = 0;
236
- return source.pipeThrough(
237
- new TransformStream<Uint8Array, Uint8Array>({
238
- transform(chunk, controller) {
239
- const from = Math.max(start - offset, 0);
240
- const to = Math.min(end + 1 - offset, chunk.byteLength);
241
- offset += chunk.byteLength;
242
- if (to > from) controller.enqueue(chunk.subarray(from, to));
243
- if (offset > end) controller.terminate();
244
- },
245
- }),
246
- );
247
- }
248
-
249
191
  /**
250
192
  * The content type is the STORED one — the upload gate sniffed those bytes and `put` recorded the
251
193
  * answer, so an extension in the URL is the only party that can lie. The validator is the driver's
@@ -277,7 +219,7 @@ export async function storageResponse(
277
219
 
278
220
  const size = object.size;
279
221
  const range = parseByteRange(request.header('range'), size);
280
- if (range === UNSATISFIABLE) {
222
+ if (range === RANGE_UNSATISFIABLE) {
281
223
  headers.set('content-range', `bytes */${size}`);
282
224
  return new Response(null, { status: 416, headers });
283
225
  }
@@ -287,9 +229,11 @@ export async function storageResponse(
287
229
  headers.set('content-length', String(size));
288
230
  return new Response(await served.open(), { headers });
289
231
  }
290
- headers.set('content-range', `bytes ${range.start}-${range.end}/${size}`);
232
+ // A disk could serve the window itself (`get(key, { range })`), but `get` buffers and is bounded
233
+ // by `maxGetBytes`; a video's `bytes=0-` probe is the whole object, which only a stream holds.
234
+ headers.set('content-range', contentRangeOf(range, size));
291
235
  headers.set('content-length', String(range.end - range.start + 1));
292
- return new Response(windowOf(await served.open(), range.start, range.end), {
236
+ return new Response(byteWindow(await served.open(), range), {
293
237
  status: 206,
294
238
  headers,
295
239
  });
@@ -5,7 +5,13 @@
5
5
 
6
6
  import { isUltimateError } from '@ultimat3/core';
7
7
  import type { CacheHint, Route, UltimateRequest } from '@ultimat3/http';
8
- import { applyCacheHeaders, jsonResponse } from '@ultimat3/http';
8
+ import {
9
+ applyCacheHeaders,
10
+ contentRangeOf,
11
+ jsonResponse,
12
+ parseByteRange,
13
+ RANGE_UNSATISFIABLE,
14
+ } from '@ultimat3/http';
9
15
  import type { SiteAsset, SiteAssetTable } from './site-assets';
10
16
  import { parseHashedAssetUrl, SITE_ASSET_BASE_PATH } from './site-assets';
11
17
 
@@ -15,36 +21,14 @@ export const SITE_ASSET_CACHE: CacheHint = { mode: 'immutable' };
15
21
  const notFound = (code: string, cause: string, fix: string): Response =>
16
22
  jsonResponse({ ok: false, error: { code, cause, fix } }, { status: 404 });
17
23
 
18
- /** One `bytes=` range, resolved against the file's size; `null` when it cannot be satisfied. */
19
- export function byteRange(
20
- header: string | null,
21
- size: number,
22
- ): { readonly start: number; readonly end: number } | null | undefined {
23
- if (header === null) return undefined;
24
- const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
25
- // A multi-range or a unit we do not speak: RFC 9110 lets a server ignore Range and send it all.
26
- if (match === null) return undefined;
27
- const [, from = '', to = ''] = match;
28
- if (from === '' && to === '') return null;
29
- if (from === '') {
30
- const suffix = Number(to);
31
- if (suffix === 0) return null;
32
- return { start: Math.max(0, size - suffix), end: size - 1 };
33
- }
34
- const start = Number(from);
35
- const end = to === '' ? size - 1 : Math.min(Number(to), size - 1);
36
- if (start >= size || end < start) return null;
37
- return { start, end };
38
- }
39
-
40
24
  function assetResponse(asset: SiteAsset, range: string | null): Response {
41
25
  const file = Bun.file(asset.file);
42
26
  const headers: Record<string, string> = {
43
27
  'content-type': asset.contentType,
44
28
  'accept-ranges': 'bytes',
45
29
  };
46
- const wanted = byteRange(range, asset.bytes);
47
- if (wanted === null) {
30
+ const wanted = parseByteRange(range, asset.bytes);
31
+ if (wanted === RANGE_UNSATISFIABLE) {
48
32
  return new Response(null, {
49
33
  status: 416,
50
34
  headers: { ...headers, 'content-range': `bytes */${String(asset.bytes)}` },
@@ -58,7 +42,7 @@ function assetResponse(asset: SiteAsset, range: string | null): Response {
58
42
  status: 206,
59
43
  headers: {
60
44
  ...headers,
61
- 'content-range': `bytes ${String(wanted.start)}-${String(wanted.end)}/${String(asset.bytes)}`,
45
+ 'content-range': contentRangeOf(wanted, asset.bytes),
62
46
  },
63
47
  }),
64
48
  SITE_ASSET_CACHE,