@eltjs/elt 0.0.0-stage → 0.1.1
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/LICENSE +202 -0
- package/README.md +1153 -2
- package/dist/core/catalog.d.ts +7 -0
- package/dist/core/catalog.js +23 -0
- package/dist/core/change-signal.d.ts +9 -0
- package/dist/core/change-signal.js +4 -0
- package/dist/core/clock.d.ts +6 -0
- package/dist/core/clock.js +1 -0
- package/dist/core/connection.d.ts +20 -0
- package/dist/core/connection.js +31 -0
- package/dist/core/copy-configuration.d.ts +24 -0
- package/dist/core/copy-configuration.js +120 -0
- package/dist/core/copy.d.ts +27 -0
- package/dist/core/copy.js +56 -0
- package/dist/core/deduplication.d.ts +14 -0
- package/dist/core/deduplication.js +96 -0
- package/dist/core/derived-dataset.d.ts +19 -0
- package/dist/core/derived-dataset.js +10 -0
- package/dist/core/derived-refreshes.d.ts +15 -0
- package/dist/core/derived-refreshes.js +120 -0
- package/dist/core/destination.d.ts +22 -0
- package/dist/core/destination.js +17 -0
- package/dist/core/document-parser.d.ts +6 -0
- package/dist/core/document-parser.js +15 -0
- package/dist/core/document-text.d.ts +28 -0
- package/dist/core/document-text.js +72 -0
- package/dist/core/file-content.d.ts +5 -0
- package/dist/core/file-content.js +24 -0
- package/dist/core/file-extension.d.ts +1 -0
- package/dist/core/file-extension.js +9 -0
- package/dist/core/file-read.d.ts +20 -0
- package/dist/core/file-read.js +79 -0
- package/dist/core/file-storage.d.ts +8 -0
- package/dist/core/file-storage.js +4 -0
- package/dist/core/file-transfer.d.ts +12 -0
- package/dist/core/file-transfer.js +65 -0
- package/dist/core/formats/base64-format.d.ts +6 -0
- package/dist/core/formats/base64-format.js +13 -0
- package/dist/core/formats/calendar-date-format.d.ts +6 -0
- package/dist/core/formats/calendar-date-format.js +13 -0
- package/dist/core/formats/decimal-format.d.ts +9 -0
- package/dist/core/formats/decimal-format.js +41 -0
- package/dist/core/formats/declared-format.d.ts +12 -0
- package/dist/core/formats/declared-format.js +48 -0
- package/dist/core/formats/field-format.d.ts +6 -0
- package/dist/core/formats/field-format.js +10 -0
- package/dist/core/formats/int64-format.d.ts +6 -0
- package/dist/core/formats/int64-format.js +16 -0
- package/dist/core/formats/local-time-format.d.ts +6 -0
- package/dist/core/formats/local-time-format.js +10 -0
- package/dist/core/formats/local-timestamp-format.d.ts +6 -0
- package/dist/core/formats/local-timestamp-format.js +10 -0
- package/dist/core/formats/temporal-format.d.ts +7 -0
- package/dist/core/formats/temporal-format.js +23 -0
- package/dist/core/formats/timestamp-format.d.ts +7 -0
- package/dist/core/formats/timestamp-format.js +14 -0
- package/dist/core/identifiers.d.ts +10 -0
- package/dist/core/identifiers.js +51 -0
- package/dist/core/interleave.d.ts +1 -0
- package/dist/core/interleave.js +38 -0
- package/dist/core/partition.d.ts +16 -0
- package/dist/core/partition.js +60 -0
- package/dist/core/pass-channels.d.ts +46 -0
- package/dist/core/pass-channels.js +65 -0
- package/dist/core/pipeline.d.ts +37 -0
- package/dist/core/pipeline.js +546 -0
- package/dist/core/reader-relation.d.ts +6 -0
- package/dist/core/reader-relation.js +11 -0
- package/dist/core/record-validation.d.ts +58 -0
- package/dist/core/record-validation.js +128 -0
- package/dist/core/reload-mode.d.ts +9 -0
- package/dist/core/reload-mode.js +17 -0
- package/dist/core/replication.d.ts +8 -0
- package/dist/core/replication.js +478 -0
- package/dist/core/rule.d.ts +29 -0
- package/dist/core/rule.js +6 -0
- package/dist/core/signal-watch.d.ts +11 -0
- package/dist/core/signal-watch.js +110 -0
- package/dist/core/snapshot.d.ts +17 -0
- package/dist/core/snapshot.js +111 -0
- package/dist/core/source.d.ts +79 -0
- package/dist/core/source.js +227 -0
- package/dist/core/stream-change.d.ts +5 -0
- package/dist/core/stream-change.js +31 -0
- package/dist/core/stream.d.ts +26 -0
- package/dist/core/stream.js +86 -0
- package/dist/core/target-description.d.ts +15 -0
- package/dist/core/target-description.js +73 -0
- package/dist/core/target.d.ts +5 -0
- package/dist/core/target.js +14 -0
- package/dist/core/upkeep.d.ts +4 -0
- package/dist/core/upkeep.js +1 -0
- package/dist/core/writer.d.ts +41 -0
- package/dist/core/writer.js +17 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +36 -0
- package/dist/state/checkpoint-store.d.ts +29 -0
- package/dist/state/checkpoint-store.js +81 -0
- package/dist/state/sync-history-relations.d.ts +72 -0
- package/dist/state/sync-history-relations.js +74 -0
- package/dist/state/sync-history.d.ts +37 -0
- package/dist/state/sync-history.js +80 -0
- package/dist/storage/local-files.d.ts +15 -0
- package/dist/storage/local-files.js +121 -0
- package/package.json +48 -4
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
export class Destination {
|
|
3
|
+
// Validate declarations without storage I/O; destinations check their own targets.
|
|
4
|
+
validate(configuration, target) {
|
|
5
|
+
this.createWriter(configuration, target);
|
|
6
|
+
}
|
|
7
|
+
validateConfiguration(configuration, target) {
|
|
8
|
+
configuration.validateSelection();
|
|
9
|
+
if (!isDeepStrictEqual(configuration.fileReads, target.fileReads))
|
|
10
|
+
throw new TypeError('Copy file reads must match the destination target');
|
|
11
|
+
if (!this.supportedDestinationSyncModes.includes(configuration.destinationSyncMode))
|
|
12
|
+
throw new TypeError(`Destination does not support ${configuration.destinationSyncMode}`);
|
|
13
|
+
}
|
|
14
|
+
async clear(configuration, target, writer, committed) {
|
|
15
|
+
await this.createWriter(configuration, target).clear(writer, committed);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// A source-side Strategy: one staged document becomes one content value.
|
|
2
|
+
export class DocumentParser {
|
|
3
|
+
identity;
|
|
4
|
+
constructor(identity) {
|
|
5
|
+
this.identity = identity;
|
|
6
|
+
if (!identity || identity.includes('\0'))
|
|
7
|
+
throw new TypeError('A parser requires a stable identity');
|
|
8
|
+
}
|
|
9
|
+
// A composite parser can identify the selected format independently, so
|
|
10
|
+
// changing an audio model does not invalidate unrelated document text.
|
|
11
|
+
// null means this parser does not handle the file; existing text can stay.
|
|
12
|
+
identityFor(_path) {
|
|
13
|
+
return Promise.resolve(this.identity);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { DocumentParser } from './document-parser.ts';
|
|
2
|
+
import { type LoadedRow, Rule, type RuleResult } from './rule.ts';
|
|
3
|
+
export declare class DocumentText extends Rule<string, string> {
|
|
4
|
+
#private;
|
|
5
|
+
readonly key: {
|
|
6
|
+
name: string;
|
|
7
|
+
description: string;
|
|
8
|
+
};
|
|
9
|
+
readonly output: {
|
|
10
|
+
readonly content: {
|
|
11
|
+
readonly type: 'text';
|
|
12
|
+
readonly description: 'The text the parser extracted. NULL unless status is computed.';
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
readonly batch = 1;
|
|
16
|
+
readonly concurrency: number;
|
|
17
|
+
constructor({ parser, files, column, }: {
|
|
18
|
+
parser: () => DocumentParser;
|
|
19
|
+
files: string;
|
|
20
|
+
column: string;
|
|
21
|
+
});
|
|
22
|
+
read(row: LoadedRow): {
|
|
23
|
+
readonly key: string;
|
|
24
|
+
readonly input: string;
|
|
25
|
+
} | null;
|
|
26
|
+
identity(ref: string): Promise<string | null>;
|
|
27
|
+
compute(refs: readonly string[], signal?: AbortSignal): Promise<readonly RuleResult<Readonly<Record<string, string>>>[]>;
|
|
28
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { lstat } from 'node:fs/promises';
|
|
2
|
+
import { availableParallelism } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { errorMessage } from '../state/sync-history.js';
|
|
5
|
+
import { Rule } from './rule.js';
|
|
6
|
+
// The text of each file a copy stored, read by a DocumentParser, so readers
|
|
7
|
+
// can search attachments with SQL. A stored file's ref names its copy by the
|
|
8
|
+
// SHA-256 of its bytes, so the ref is both the key and the input, and rows of
|
|
9
|
+
// different tables that hold one file share its text.
|
|
10
|
+
export class DocumentText extends Rule {
|
|
11
|
+
key = {
|
|
12
|
+
name: 'ref',
|
|
13
|
+
description: "Path of the stored copy, relative to the import's files folder: the value of the file column of every row whose file it is. Join that column to it.",
|
|
14
|
+
};
|
|
15
|
+
output = {
|
|
16
|
+
content: {
|
|
17
|
+
type: 'text',
|
|
18
|
+
description: 'The text the parser extracted. NULL unless status is computed.',
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
// One file per call, as many at once as the Mac has cores; an image read
|
|
22
|
+
// beyond the OCR workers waits in the host's pool.
|
|
23
|
+
batch = 1;
|
|
24
|
+
concurrency = availableParallelism();
|
|
25
|
+
#parser;
|
|
26
|
+
#files;
|
|
27
|
+
#column;
|
|
28
|
+
// parser: asked once per refresh, so a host's changed choice, such as a
|
|
29
|
+
// speech model, applies from the next refresh. files: the folder the
|
|
30
|
+
// column's refs are relative to.
|
|
31
|
+
constructor({ parser, files, column, }) {
|
|
32
|
+
super();
|
|
33
|
+
this.#parser = parser;
|
|
34
|
+
this.#files = files;
|
|
35
|
+
this.#column = column;
|
|
36
|
+
Object.freeze(this);
|
|
37
|
+
}
|
|
38
|
+
read(row) {
|
|
39
|
+
const ref = row[this.#column];
|
|
40
|
+
return typeof ref === 'string' ? { key: ref, input: ref } : null;
|
|
41
|
+
}
|
|
42
|
+
identity(ref) {
|
|
43
|
+
return this.#parser().identityFor(join(this.#files, ref));
|
|
44
|
+
}
|
|
45
|
+
async compute(refs, signal) {
|
|
46
|
+
const parser = this.#parser();
|
|
47
|
+
return Promise.all(refs.map(async (ref) => {
|
|
48
|
+
const file = join(this.#files, ref);
|
|
49
|
+
try {
|
|
50
|
+
const content = await parser.parse(file, signal);
|
|
51
|
+
return content === null ? { empty: true } : { value: { content } };
|
|
52
|
+
}
|
|
53
|
+
catch (failure) {
|
|
54
|
+
if (signal?.aborted ||
|
|
55
|
+
(failure instanceof Error && failure.name === 'AbortError'))
|
|
56
|
+
return { gone: true };
|
|
57
|
+
// A commit removed the copy after the refresh listed it; its rows
|
|
58
|
+
// are gone too, so the file is not a failure.
|
|
59
|
+
if (await removed(file))
|
|
60
|
+
return { gone: true };
|
|
61
|
+
return { error: errorMessage(failure) };
|
|
62
|
+
}
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function removed(path) {
|
|
67
|
+
return lstat(path).then(() => false, (error) => {
|
|
68
|
+
if (error.code === 'ENOENT')
|
|
69
|
+
return true;
|
|
70
|
+
throw error;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { open } from 'node:fs/promises';
|
|
2
|
+
// A source file's bytes, read in bounded chunks so no file is ever held in
|
|
3
|
+
// memory whole. Valid until the consumer advances past the record carrying it.
|
|
4
|
+
export class FileContent {
|
|
5
|
+
path;
|
|
6
|
+
constructor(path) {
|
|
7
|
+
this.path = path;
|
|
8
|
+
Object.freeze(this);
|
|
9
|
+
}
|
|
10
|
+
async *chunks(size) {
|
|
11
|
+
if (!Number.isSafeInteger(size) || size <= 0)
|
|
12
|
+
throw new TypeError('Chunk size must be a positive integer');
|
|
13
|
+
await using file = await open(this.path);
|
|
14
|
+
let remaining = (await file.stat()).size;
|
|
15
|
+
while (remaining > 0) {
|
|
16
|
+
const chunk = new Uint8Array(Math.min(size, remaining));
|
|
17
|
+
const { bytesRead } = await file.read(chunk, 0, chunk.length, null);
|
|
18
|
+
if (bytesRead === 0)
|
|
19
|
+
throw new Error(`File ${this.path} shrank while it was read`);
|
|
20
|
+
remaining -= bytesRead;
|
|
21
|
+
yield chunk.subarray(0, bytesRead);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function fileExtension(name: string): string;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { extname } from 'node:path';
|
|
2
|
+
// The extension a file named name keeps when a source stages it or a file
|
|
3
|
+
// storage stores it: its own when it is 1 to 16 letters or digits, else none.
|
|
4
|
+
// A name from an upstream is untrusted, and one byte past 255 in a file name
|
|
5
|
+
// fails the write; a parser still picks a file's format by a short extension.
|
|
6
|
+
export function fileExtension(name) {
|
|
7
|
+
const extension = extname(name);
|
|
8
|
+
return /^\.[a-zA-Z0-9]{1,16}$/.test(extension) ? extension : '';
|
|
9
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { DocumentParser } from './document-parser.ts';
|
|
2
|
+
import { FileStorage } from './file-storage.ts';
|
|
3
|
+
import type { Stream } from './stream.ts';
|
|
4
|
+
export declare class FileReference {
|
|
5
|
+
readonly stream: Stream;
|
|
6
|
+
readonly storage?: FileStorage;
|
|
7
|
+
constructor(stream: Stream, storage?: FileStorage);
|
|
8
|
+
store(storage: FileStorage): FileReference;
|
|
9
|
+
}
|
|
10
|
+
export declare class FileRead {
|
|
11
|
+
readonly name: string;
|
|
12
|
+
readonly file: FileReference;
|
|
13
|
+
readonly parser?: DocumentParser;
|
|
14
|
+
readonly parserIdentity?: string;
|
|
15
|
+
readonly storageIdentity?: string;
|
|
16
|
+
constructor(name: string, file: FileReference, parser?: DocumentParser);
|
|
17
|
+
validate(stream: Stream): void;
|
|
18
|
+
toJSON(): object;
|
|
19
|
+
get outputType(): 'text' | 'bytes';
|
|
20
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { DocumentParser } from './document-parser.js';
|
|
2
|
+
import { FileStorage } from './file-storage.js';
|
|
3
|
+
// A source resource, not a local path. Only the source can export its contents.
|
|
4
|
+
export class FileReference {
|
|
5
|
+
stream;
|
|
6
|
+
storage;
|
|
7
|
+
constructor(stream, storage) {
|
|
8
|
+
this.stream = stream;
|
|
9
|
+
this.storage = storage;
|
|
10
|
+
if (storage !== undefined &&
|
|
11
|
+
(!(storage instanceof FileStorage) ||
|
|
12
|
+
typeof storage.identity !== 'string' ||
|
|
13
|
+
storage.identity.length === 0 ||
|
|
14
|
+
!storage.identity.isWellFormed() ||
|
|
15
|
+
storage.identity.includes('\0')))
|
|
16
|
+
throw new TypeError('File storage requires a stable text identity');
|
|
17
|
+
Object.freeze(this);
|
|
18
|
+
}
|
|
19
|
+
store(storage) {
|
|
20
|
+
return new FileReference(this.stream, storage);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// A named extraction request. A stored file becomes a scalar reference before
|
|
24
|
+
// its destination receives it; parsing and byte reads remain separate choices.
|
|
25
|
+
export class FileRead {
|
|
26
|
+
name;
|
|
27
|
+
file;
|
|
28
|
+
parser;
|
|
29
|
+
parserIdentity;
|
|
30
|
+
storageIdentity;
|
|
31
|
+
constructor(name, file, parser) {
|
|
32
|
+
this.name = name;
|
|
33
|
+
this.file = file;
|
|
34
|
+
this.parser = parser;
|
|
35
|
+
if (!name || name.includes('\0'))
|
|
36
|
+
throw new TypeError('Invalid file field name');
|
|
37
|
+
if (!(file instanceof FileReference))
|
|
38
|
+
throw new TypeError('File extraction requires a source file reference');
|
|
39
|
+
if (parser !== undefined && !(parser instanceof DocumentParser))
|
|
40
|
+
throw new TypeError('parser must be a DocumentParser');
|
|
41
|
+
if (parser !== undefined && file.storage !== undefined)
|
|
42
|
+
throw new TypeError('Select parsing and storage as separate file fields');
|
|
43
|
+
this.parserIdentity = parser?.identity;
|
|
44
|
+
this.storageIdentity = file.storage?.identity;
|
|
45
|
+
Object.freeze(this);
|
|
46
|
+
}
|
|
47
|
+
validate(stream) {
|
|
48
|
+
if (this.file.stream.name !== stream.name)
|
|
49
|
+
throw new TypeError('File reference belongs to another stream');
|
|
50
|
+
if (this.file.stream.supportsFileTransfer !== true ||
|
|
51
|
+
stream.supportsFileTransfer !== true)
|
|
52
|
+
throw new TypeError(`Stream ${stream.name} does not support file extraction`);
|
|
53
|
+
if (this.parser?.identity !== this.parserIdentity)
|
|
54
|
+
throw new TypeError('Parser identity changed after configuration');
|
|
55
|
+
if (this.file.storage?.identity !== this.storageIdentity)
|
|
56
|
+
throw new TypeError('File storage identity changed after configuration');
|
|
57
|
+
const { properties } = stream.jsonSchema;
|
|
58
|
+
if (stream.jsonSchema.type !== 'object' ||
|
|
59
|
+
properties === null ||
|
|
60
|
+
typeof properties !== 'object' ||
|
|
61
|
+
Array.isArray(properties))
|
|
62
|
+
throw new TypeError('File extraction requires an object metadata schema');
|
|
63
|
+
if (Object.keys(properties).some((name) => name.toLowerCase() === this.name.toLowerCase()))
|
|
64
|
+
throw new TypeError('File field collides with source metadata');
|
|
65
|
+
}
|
|
66
|
+
toJSON() {
|
|
67
|
+
return {
|
|
68
|
+
name: this.name,
|
|
69
|
+
stream: this.file.stream.name,
|
|
70
|
+
parser: this.parserIdentity,
|
|
71
|
+
storage: this.storageIdentity,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
get outputType() {
|
|
75
|
+
return this.parser !== undefined || this.file.storage !== undefined
|
|
76
|
+
? 'text'
|
|
77
|
+
: 'bytes';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { FileContent } from './file-content.ts';
|
|
2
|
+
export declare abstract class FileStorage {
|
|
3
|
+
abstract readonly identity: string;
|
|
4
|
+
abstract readonly reference: string;
|
|
5
|
+
abstract save(scope: string, content: FileContent): Promise<string>;
|
|
6
|
+
abstract flush(scope: string): Promise<void>;
|
|
7
|
+
abstract retain(scope: string, references: ReadonlySet<string>): Promise<void>;
|
|
8
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { FileRead } from './file-read.ts';
|
|
2
|
+
import type { FieldValues } from './writer.ts';
|
|
3
|
+
export declare class FileTransfer {
|
|
4
|
+
readonly reads: readonly FileRead[];
|
|
5
|
+
readonly location: string;
|
|
6
|
+
readonly writer: string;
|
|
7
|
+
constructor(reads: readonly FileRead[], location: string, writer: string);
|
|
8
|
+
private scope;
|
|
9
|
+
record(record: unknown): Promise<unknown>;
|
|
10
|
+
flush(): Promise<void>;
|
|
11
|
+
reconcile(values: FieldValues): Promise<void>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { FileContent } from './file-content.js';
|
|
2
|
+
// The shared transfer owns coordination. Stores own bytes; destinations only
|
|
3
|
+
// expose the scalar values their rows actually retain after deduplication.
|
|
4
|
+
export class FileTransfer {
|
|
5
|
+
reads;
|
|
6
|
+
location;
|
|
7
|
+
writer;
|
|
8
|
+
constructor(reads, location, writer) {
|
|
9
|
+
this.reads = reads;
|
|
10
|
+
this.location = location;
|
|
11
|
+
this.writer = writer;
|
|
12
|
+
}
|
|
13
|
+
scope(read) {
|
|
14
|
+
return JSON.stringify({
|
|
15
|
+
location: this.location,
|
|
16
|
+
writer: this.writer,
|
|
17
|
+
field: read.name,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
async record(record) {
|
|
21
|
+
let data = record;
|
|
22
|
+
for (const read of this.reads) {
|
|
23
|
+
const storage = read.file.storage;
|
|
24
|
+
if (storage === undefined)
|
|
25
|
+
continue;
|
|
26
|
+
const content = Reflect.get(Object(data), read.name);
|
|
27
|
+
if (content === null)
|
|
28
|
+
continue;
|
|
29
|
+
if (!(content instanceof FileContent))
|
|
30
|
+
throw new TypeError('Stored files require source file content');
|
|
31
|
+
const reference = await storage.save(this.scope(read), content);
|
|
32
|
+
if (typeof reference !== 'string' ||
|
|
33
|
+
!reference ||
|
|
34
|
+
reference.includes('\0') ||
|
|
35
|
+
!reference.isWellFormed())
|
|
36
|
+
throw new TypeError('File storage must return a nonempty text reference');
|
|
37
|
+
data = { ...Object(data), [read.name]: reference };
|
|
38
|
+
}
|
|
39
|
+
return data;
|
|
40
|
+
}
|
|
41
|
+
async flush() {
|
|
42
|
+
for (const read of this.reads) {
|
|
43
|
+
const storage = read.file.storage;
|
|
44
|
+
if (storage === undefined)
|
|
45
|
+
continue;
|
|
46
|
+
await storage.flush(this.scope(read));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async reconcile(values) {
|
|
50
|
+
for (const read of this.reads) {
|
|
51
|
+
const storage = read.file.storage;
|
|
52
|
+
if (storage === undefined)
|
|
53
|
+
continue;
|
|
54
|
+
const references = new Set();
|
|
55
|
+
for await (const value of values(read.name)) {
|
|
56
|
+
if (value === null)
|
|
57
|
+
continue;
|
|
58
|
+
if (typeof value !== 'string')
|
|
59
|
+
throw new TypeError('Stored file references must be text or null');
|
|
60
|
+
references.add(value);
|
|
61
|
+
}
|
|
62
|
+
await storage.retain(this.scope(read), references);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { FieldFormat } from './field-format.js';
|
|
2
|
+
// contentEncoding: 'base64': bytes as padded RFC 4648 base64, the one
|
|
3
|
+
// spelling its bytes have. The alphabet does not sort as bytes do, so it
|
|
4
|
+
// cannot be a cursor.
|
|
5
|
+
export class Base64Format extends FieldFormat {
|
|
6
|
+
name = 'base64';
|
|
7
|
+
orderable = false;
|
|
8
|
+
accepts(value) {
|
|
9
|
+
return (/^[A-Za-z0-9+/]*={0,2}$/.test(value) &&
|
|
10
|
+
value.length % 4 === 0 &&
|
|
11
|
+
Buffer.from(value, 'base64').toString('base64') === value);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { FieldFormat } from './field-format.js';
|
|
2
|
+
import { isTimestamp } from './timestamp-format.js';
|
|
3
|
+
// format: 'date': a day of the Gregorian calendar.
|
|
4
|
+
export class CalendarDateFormat extends FieldFormat {
|
|
5
|
+
name = 'date';
|
|
6
|
+
accepts(value) {
|
|
7
|
+
return (/^\d{4}-\d{2}-\d{2}$/.test(value) && isTimestamp(`${value}T00:00:00.000Z`));
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
const day = new CalendarDateFormat();
|
|
11
|
+
export function isCalendarDate(value) {
|
|
12
|
+
return typeof value === 'string' && day.accepts(value);
|
|
13
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { FieldFormat } from './field-format.ts';
|
|
2
|
+
export declare class DecimalFormat extends FieldFormat {
|
|
3
|
+
readonly name = "decimal";
|
|
4
|
+
readonly orderable = false;
|
|
5
|
+
readonly precision: number | undefined;
|
|
6
|
+
readonly scale: number | undefined;
|
|
7
|
+
constructor(precision?: number, scale?: number);
|
|
8
|
+
accepts(value: string): boolean;
|
|
9
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { FieldFormat } from './field-format.js';
|
|
2
|
+
// format: 'decimal': an exact decimal number as text, never through a binary
|
|
3
|
+
// float. With a scale, a value has exactly that many fraction digits, as SQL
|
|
4
|
+
// writes NUMERIC(p,s) as text; without one, its fraction has no trailing
|
|
5
|
+
// zeros. Either way each value has one spelling, so equal keys are equal
|
|
6
|
+
// text. Text does not sort as the numbers do, so it cannot be a cursor.
|
|
7
|
+
export class DecimalFormat extends FieldFormat {
|
|
8
|
+
name = 'decimal';
|
|
9
|
+
orderable = false;
|
|
10
|
+
// Most significant digits a value has, before and after the point.
|
|
11
|
+
precision;
|
|
12
|
+
scale;
|
|
13
|
+
constructor(precision, scale) {
|
|
14
|
+
super();
|
|
15
|
+
if (precision !== undefined &&
|
|
16
|
+
(!Number.isSafeInteger(precision) || precision < 1))
|
|
17
|
+
throw new TypeError('Decimal precision must be a positive integer');
|
|
18
|
+
if (scale !== undefined &&
|
|
19
|
+
(precision === undefined ||
|
|
20
|
+
!Number.isSafeInteger(scale) ||
|
|
21
|
+
scale < 0 ||
|
|
22
|
+
scale > precision))
|
|
23
|
+
throw new TypeError('Decimal scale must be an integer from 0 to its precision');
|
|
24
|
+
this.precision = precision;
|
|
25
|
+
this.scale = scale;
|
|
26
|
+
}
|
|
27
|
+
accepts(value) {
|
|
28
|
+
const match = /^-?(0|[1-9]\d*)(?:\.(\d+))?$/.exec(value);
|
|
29
|
+
if (match === null)
|
|
30
|
+
return false;
|
|
31
|
+
const [, integer = '', fraction = ''] = match;
|
|
32
|
+
if (value.startsWith('-') && /^0+$/.test(integer + fraction))
|
|
33
|
+
return false;
|
|
34
|
+
if (this.scale === undefined
|
|
35
|
+
? fraction.endsWith('0')
|
|
36
|
+
: fraction.length !== this.scale)
|
|
37
|
+
return false;
|
|
38
|
+
return (this.precision === undefined ||
|
|
39
|
+
(integer === '0' ? 0 : integer.length) + fraction.length <= this.precision);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ScalarSchema } from '../record-validation.ts';
|
|
2
|
+
import { Base64Format } from './base64-format.ts';
|
|
3
|
+
import { CalendarDateFormat } from './calendar-date-format.ts';
|
|
4
|
+
import { DecimalFormat } from './decimal-format.ts';
|
|
5
|
+
import { Int64Format } from './int64-format.ts';
|
|
6
|
+
import { LocalTimeFormat } from './local-time-format.ts';
|
|
7
|
+
import { LocalTimestampFormat } from './local-timestamp-format.ts';
|
|
8
|
+
import { TimestampFormat } from './timestamp-format.ts';
|
|
9
|
+
export type DeclaredFormat = TimestampFormat | LocalTimestampFormat | LocalTimeFormat | CalendarDateFormat | Int64Format | DecimalFormat | Base64Format;
|
|
10
|
+
export declare function declaredFormat(schema: ScalarSchema & {
|
|
11
|
+
readonly type: string | readonly string[];
|
|
12
|
+
}): DeclaredFormat | null;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Base64Format } from './base64-format.js';
|
|
2
|
+
import { CalendarDateFormat } from './calendar-date-format.js';
|
|
3
|
+
import { DecimalFormat } from './decimal-format.js';
|
|
4
|
+
import { Int64Format } from './int64-format.js';
|
|
5
|
+
import { LocalTimeFormat } from './local-time-format.js';
|
|
6
|
+
import { LocalTimestampFormat } from './local-timestamp-format.js';
|
|
7
|
+
import { TimestampFormat } from './timestamp-format.js';
|
|
8
|
+
// The format a field or array item declares, with the keywords that refine
|
|
9
|
+
// it; null for one that declares none. Every format spells a string.
|
|
10
|
+
export function declaredFormat(schema) {
|
|
11
|
+
const { format, precision, scale, contentEncoding } = schema;
|
|
12
|
+
if (format === undefined && contentEncoding === undefined) {
|
|
13
|
+
if (precision !== undefined || scale !== undefined)
|
|
14
|
+
throw new TypeError('precision and scale refine a format');
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
const types = typeof schema.type === 'string' ? [schema.type] : schema.type;
|
|
18
|
+
if (types.filter((type) => type !== 'null').join() !== 'string')
|
|
19
|
+
throw new TypeError(`${format === undefined ? `contentEncoding ${contentEncoding}` : format} spells a string`);
|
|
20
|
+
if (contentEncoding !== undefined) {
|
|
21
|
+
if (contentEncoding !== 'base64' ||
|
|
22
|
+
format !== undefined ||
|
|
23
|
+
precision !== undefined ||
|
|
24
|
+
scale !== undefined)
|
|
25
|
+
throw new TypeError('contentEncoding is base64 alone');
|
|
26
|
+
return new Base64Format();
|
|
27
|
+
}
|
|
28
|
+
if (scale !== undefined && format !== 'decimal')
|
|
29
|
+
throw new TypeError('scale refines a decimal');
|
|
30
|
+
if (precision !== undefined && (format === 'date' || format === 'int64'))
|
|
31
|
+
throw new TypeError(`format ${format} has no precision`);
|
|
32
|
+
switch (format) {
|
|
33
|
+
case 'date-time':
|
|
34
|
+
return new TimestampFormat(precision);
|
|
35
|
+
case 'date-time-local':
|
|
36
|
+
return new LocalTimestampFormat(precision);
|
|
37
|
+
case 'time-local':
|
|
38
|
+
return new LocalTimeFormat(precision);
|
|
39
|
+
case 'date':
|
|
40
|
+
return new CalendarDateFormat();
|
|
41
|
+
case 'int64':
|
|
42
|
+
return new Int64Format();
|
|
43
|
+
case 'decimal':
|
|
44
|
+
return new DecimalFormat(precision, scale);
|
|
45
|
+
default:
|
|
46
|
+
throw new TypeError(`Unsupported format ${String(format)}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// The canonical spelling a string field declares through `format` or
|
|
2
|
+
// `contentEncoding`, and how two canonical values order when a copy compares
|
|
3
|
+
// them as cursors.
|
|
4
|
+
export class FieldFormat {
|
|
5
|
+
// False when byte order is not the values' order, so the field cannot be a
|
|
6
|
+
// cursor.
|
|
7
|
+
orderable = true;
|
|
8
|
+
// How cursors of this format compare, as target descriptions state it.
|
|
9
|
+
ordering = 'by byte order';
|
|
10
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { FieldFormat } from './field-format.js';
|
|
2
|
+
const min = -(2n ** 63n);
|
|
3
|
+
const max = 2n ** 63n - 1n;
|
|
4
|
+
// format: 'int64': a signed 64-bit integer as decimal text without leading
|
|
5
|
+
// zeros, so a value past Number.MAX_SAFE_INTEGER keeps every digit through
|
|
6
|
+
// JSON. Its text does not sort as its value, so cursors compare the integers.
|
|
7
|
+
export class Int64Format extends FieldFormat {
|
|
8
|
+
name = 'int64';
|
|
9
|
+
ordering = 'as 64-bit integers';
|
|
10
|
+
accepts(value) {
|
|
11
|
+
if (!/^-?(0|[1-9]\d*)$/.test(value) || value === '-0')
|
|
12
|
+
return false;
|
|
13
|
+
const integer = BigInt(value);
|
|
14
|
+
return integer >= min && integer <= max;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { TemporalFormat } from './temporal-format.js';
|
|
2
|
+
// format: 'time-local': a time of day with no zone, as SQL's TIME stores it.
|
|
3
|
+
// JSON Schema's own `time` requires an offset, so it cannot carry one.
|
|
4
|
+
export class LocalTimeFormat extends TemporalFormat {
|
|
5
|
+
name = 'time-local';
|
|
6
|
+
#pattern = new RegExp(`^\\d{2}:\\d{2}:\\d{2}${this.fraction}$`);
|
|
7
|
+
accepts(value) {
|
|
8
|
+
return (this.#pattern.test(value) && this.real(`1970-01-01T${value.slice(0, 8)}`));
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { TemporalFormat } from './temporal-format.js';
|
|
2
|
+
// format: 'date-time-local': a wall-clock date and time with no zone, as
|
|
3
|
+
// SQL's TIMESTAMP WITHOUT TIME ZONE stores it.
|
|
4
|
+
export class LocalTimestampFormat extends TemporalFormat {
|
|
5
|
+
name = 'date-time-local';
|
|
6
|
+
#pattern = new RegExp(`^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}${this.fraction}$`);
|
|
7
|
+
accepts(value) {
|
|
8
|
+
return this.#pattern.test(value) && this.real(value.slice(0, 19));
|
|
9
|
+
}
|
|
10
|
+
}
|