@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 +28 -28
- package/src/cmd-db.ts +2 -0
- package/src/db-generate.ts +19 -8
- package/src/runtime-storage.ts +13 -69
- package/src/site-asset-routes.ts +10 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "29.
|
|
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.
|
|
43
|
-
"@ultimat3/admin": "29.
|
|
44
|
-
"@ultimat3/ai": "29.
|
|
45
|
-
"@ultimat3/auth": "29.
|
|
46
|
-
"@ultimat3/cache": "29.
|
|
47
|
-
"@ultimat3/core": "29.
|
|
48
|
-
"@ultimat3/db": "29.
|
|
49
|
-
"@ultimat3/entity": "29.
|
|
50
|
-
"@ultimat3/flags": "29.
|
|
51
|
-
"@ultimat3/http": "29.
|
|
52
|
-
"@ultimat3/i18n": "29.
|
|
53
|
-
"@ultimat3/jobs": "29.
|
|
54
|
-
"@ultimat3/mail": "29.
|
|
55
|
-
"@ultimat3/manifest": "29.
|
|
56
|
-
"@ultimat3/mcp": "29.
|
|
57
|
-
"@ultimat3/money": "29.
|
|
58
|
-
"@ultimat3/notify": "29.
|
|
59
|
-
"@ultimat3/policy": "29.
|
|
60
|
-
"@ultimat3/pwa": "29.
|
|
61
|
-
"@ultimat3/query": "29.
|
|
62
|
-
"@ultimat3/realtime": "29.
|
|
63
|
-
"@ultimat3/render": "29.
|
|
64
|
-
"@ultimat3/schema": "29.
|
|
65
|
-
"@ultimat3/seo": "29.
|
|
66
|
-
"@ultimat3/storage": "29.
|
|
67
|
-
"@ultimat3/testing": "29.
|
|
68
|
-
"@ultimat3/time": "29.
|
|
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,
|
package/src/db-generate.ts
CHANGED
|
@@ -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
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
182
|
+
files,
|
|
172
183
|
findings: [],
|
|
173
184
|
unrendered: migration.unrendered,
|
|
174
185
|
};
|
package/src/runtime-storage.ts
CHANGED
|
@@ -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 {
|
|
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 ===
|
|
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
|
-
|
|
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(
|
|
236
|
+
return new Response(byteWindow(await served.open(), range), {
|
|
293
237
|
status: 206,
|
|
294
238
|
headers,
|
|
295
239
|
});
|
package/src/site-asset-routes.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
47
|
-
if (wanted ===
|
|
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':
|
|
45
|
+
'content-range': contentRangeOf(wanted, asset.bytes),
|
|
62
46
|
},
|
|
63
47
|
}),
|
|
64
48
|
SITE_ASSET_CACHE,
|