@chronicle.app/schema 0.3.0 → 0.5.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/README.md +89 -19
- package/chronicle.ttl +1023 -579
- package/dist/datetime.d.ts +10 -0
- package/dist/datetime.js +49 -0
- package/dist/identity.d.ts +59 -0
- package/dist/identity.js +166 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/schema.d.ts +317 -74
- package/dist/schema.js +1227 -419
- package/package.json +4 -3
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Whether `value` is a full instant in UTC, such as `2024-03-02T14:05:00Z`. */
|
|
2
|
+
export declare function isInstant(value: string): boolean;
|
|
3
|
+
/**
|
|
4
|
+
* Whether `value` is a :DateTime string: a full instant in UTC
|
|
5
|
+
* (`2024-03-02T14:05:00Z`), or a date at year, month, or day precision that
|
|
6
|
+
* may leave trailing year digits, the month, or the day unspecified with `X`
|
|
7
|
+
* and may end in one qualifier (`?` uncertain, `~` approximate, `%` both).
|
|
8
|
+
*/
|
|
9
|
+
export declare function isDateTime(value: string): boolean;
|
|
10
|
+
//# sourceMappingURL=datetime.d.ts.map
|
package/dist/datetime.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// A :DateTime string is one point in time, written in a subset of EDTF
|
|
2
|
+
// (ISO 8601-2). See the :DateTime comment in chronicle.ttl for the forms.
|
|
3
|
+
// A year with any trailing digits unspecified: 1987, 198X, 19XX, 1XXX, XXXX.
|
|
4
|
+
const YEAR = String.raw `\d{4}|\d{3}X|\d{2}XX|\dXXX|XXXX`;
|
|
5
|
+
const DATE = new RegExp(String.raw `^(?<year>${YEAR})(?:-(?<month>\d{2}|XX)(?:-(?<day>\d{2}|XX))?)?(?<qualifier>[?~%])?$`);
|
|
6
|
+
const INSTANT = /^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})T(?<hour>\d{2}):(?<minute>\d{2}):(?<second>\d{2})(?:\.\d+)?Z$/;
|
|
7
|
+
const daysIn = (year, month) => new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
8
|
+
const validDay = (year, month, day) => {
|
|
9
|
+
if (month === 'XX' || day === 'XX')
|
|
10
|
+
return true;
|
|
11
|
+
const days = /X/.test(year) ? daysIn(2000, Number(month)) : daysIn(Number(year), Number(month));
|
|
12
|
+
return Number(day) >= 1 && Number(day) <= days;
|
|
13
|
+
};
|
|
14
|
+
/** Whether `value` is a full instant in UTC, such as `2024-03-02T14:05:00Z`. */
|
|
15
|
+
export function isInstant(value) {
|
|
16
|
+
const instant = INSTANT.exec(value)?.groups;
|
|
17
|
+
if (!instant)
|
|
18
|
+
return false;
|
|
19
|
+
const { year, month, day, hour, minute, second } = instant;
|
|
20
|
+
return (Number(month) >= 1 &&
|
|
21
|
+
Number(month) <= 12 &&
|
|
22
|
+
validDay(year, month, day) &&
|
|
23
|
+
Number(hour) <= 23 &&
|
|
24
|
+
Number(minute) <= 59 &&
|
|
25
|
+
Number(second) <= 59);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Whether `value` is a :DateTime string: a full instant in UTC
|
|
29
|
+
* (`2024-03-02T14:05:00Z`), or a date at year, month, or day precision that
|
|
30
|
+
* may leave trailing year digits, the month, or the day unspecified with `X`
|
|
31
|
+
* and may end in one qualifier (`?` uncertain, `~` approximate, `%` both).
|
|
32
|
+
*/
|
|
33
|
+
export function isDateTime(value) {
|
|
34
|
+
if (INSTANT.test(value))
|
|
35
|
+
return isInstant(value);
|
|
36
|
+
const date = DATE.exec(value)?.groups;
|
|
37
|
+
if (!date)
|
|
38
|
+
return false;
|
|
39
|
+
const { year, month, day } = date;
|
|
40
|
+
// An unspecified month with a known day says nothing usable; neither does
|
|
41
|
+
// a value with no known digit at all.
|
|
42
|
+
if (month === 'XX' && day !== undefined && day !== 'XX')
|
|
43
|
+
return false;
|
|
44
|
+
if (year === 'XXXX' && (month === undefined || month === 'XX'))
|
|
45
|
+
return false;
|
|
46
|
+
if (month === undefined || month === 'XX')
|
|
47
|
+
return true;
|
|
48
|
+
return (Number(month) >= 1 && Number(month) <= 12 && (day === undefined || validDay(year, month, day)));
|
|
49
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { KeyField } from './schema.js';
|
|
2
|
+
/**
|
|
3
|
+
* A node's identity as one string, whatever its source or the shape of its
|
|
4
|
+
* `@key`: the hash of its keyset, the values of the fields its `@key` names.
|
|
5
|
+
* Extractors and stores derive it the same way, so one node can name another
|
|
6
|
+
* by it, such as from its text, without knowing how the other is keyed. The
|
|
7
|
+
* hash must never change: every stored identity is one.
|
|
8
|
+
*/
|
|
9
|
+
/** A node, as far as its identity goes: its `@key` or `@id`, and the fields they name. */
|
|
10
|
+
export interface KeyedNode {
|
|
11
|
+
'@type'?: unknown;
|
|
12
|
+
'@key'?: KeyField[];
|
|
13
|
+
'@id'?: unknown;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The source a node identified by nothing but its URL belongs to. A URL is a
|
|
17
|
+
* portable identifier, like an email address or a phone number, so it belongs
|
|
18
|
+
* to no product, whichever source saw it.
|
|
19
|
+
*/
|
|
20
|
+
export declare const URL_SOURCE = "url";
|
|
21
|
+
/**
|
|
22
|
+
* Deep, recursive, stable-key canonicalization: object keys are sorted at
|
|
23
|
+
* every level and undefined values dropped, so equal content always produces
|
|
24
|
+
* identical bytes. Array order is kept, since it means something. A `Date` is
|
|
25
|
+
* the ISO string a store gets for it, once it's serialized.
|
|
26
|
+
*/
|
|
27
|
+
export declare function canonicalize(value: unknown): unknown;
|
|
28
|
+
/** Canonical JSON: the same string whatever order the keys were added in. */
|
|
29
|
+
export declare function canonicalJson(value: unknown): string;
|
|
30
|
+
/**
|
|
31
|
+
* Lowercase base32 (RFC 4648, no padding) of the sha256 of the bytes: 52
|
|
32
|
+
* characters, shorter than hex and safe in URLs and on case-insensitive
|
|
33
|
+
* filesystems.
|
|
34
|
+
*/
|
|
35
|
+
export declare function base32Sha256(bytes: Uint8Array): string;
|
|
36
|
+
/**
|
|
37
|
+
* The hash of a keyset: the base32 sha256 of its canonical JSON, the same
|
|
38
|
+
* however its keys are ordered.
|
|
39
|
+
*/
|
|
40
|
+
export declare function generateKeysetCID(keyset: Record<string, unknown>): string;
|
|
41
|
+
/**
|
|
42
|
+
* The keyset a node's `@key` names: each field's value by its path, with dots
|
|
43
|
+
* and `[*]` splats followed into nested values and a list joined with commas.
|
|
44
|
+
* `source` is the source given, and an `action.` field is read from the action
|
|
45
|
+
* the node is in. A computed entry `{ key, value }` is `key:value`. Fields
|
|
46
|
+
* with no value are left out, and a keyset with none is null, so it never
|
|
47
|
+
* names one node shared by unrelated records. A key of nothing but `url` is
|
|
48
|
+
* the `url` source's, so it names the same node whichever source saw it.
|
|
49
|
+
*/
|
|
50
|
+
export declare function keysetOf(node: KeyedNode, source: string | undefined, actionContext?: Record<string, unknown>): Record<string, unknown> | null;
|
|
51
|
+
/**
|
|
52
|
+
* A node's identity: the hash of the keyset its `@key` names, or its `@id`,
|
|
53
|
+
* which is that hash already. With both, they must agree. With neither, it's
|
|
54
|
+
* its `url`, as the `url` source's, or else its `attachmentCid`, the stored
|
|
55
|
+
* file it is. `source` is the node's own unless given, as for a node in a
|
|
56
|
+
* record of that source that names none.
|
|
57
|
+
*/
|
|
58
|
+
export declare function nodeKey(node: KeyedNode, source?: string | undefined, actionContext?: Record<string, unknown>): string;
|
|
59
|
+
//# sourceMappingURL=identity.d.ts.map
|
package/dist/identity.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { sha256 } from '@noble/hashes/sha2.js';
|
|
2
|
+
/**
|
|
3
|
+
* The source a node identified by nothing but its URL belongs to. A URL is a
|
|
4
|
+
* portable identifier, like an email address or a phone number, so it belongs
|
|
5
|
+
* to no product, whichever source saw it.
|
|
6
|
+
*/
|
|
7
|
+
export const URL_SOURCE = 'url';
|
|
8
|
+
/**
|
|
9
|
+
* Deep, recursive, stable-key canonicalization: object keys are sorted at
|
|
10
|
+
* every level and undefined values dropped, so equal content always produces
|
|
11
|
+
* identical bytes. Array order is kept, since it means something. A `Date` is
|
|
12
|
+
* the ISO string a store gets for it, once it's serialized.
|
|
13
|
+
*/
|
|
14
|
+
export function canonicalize(value) {
|
|
15
|
+
if (value instanceof Date) {
|
|
16
|
+
return value.toISOString();
|
|
17
|
+
}
|
|
18
|
+
if (Array.isArray(value)) {
|
|
19
|
+
return value.map(v => canonicalize(v));
|
|
20
|
+
}
|
|
21
|
+
if (value !== null && typeof value === 'object') {
|
|
22
|
+
const source = value;
|
|
23
|
+
const out = {};
|
|
24
|
+
for (const key of Object.keys(source).sort()) {
|
|
25
|
+
const v = canonicalize(source[key]);
|
|
26
|
+
if (v !== undefined)
|
|
27
|
+
out[key] = v;
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
/** Canonical JSON: the same string whatever order the keys were added in. */
|
|
34
|
+
export function canonicalJson(value) {
|
|
35
|
+
return JSON.stringify(canonicalize(value));
|
|
36
|
+
}
|
|
37
|
+
const BASE32 = 'abcdefghijklmnopqrstuvwxyz234567';
|
|
38
|
+
/**
|
|
39
|
+
* Lowercase base32 (RFC 4648, no padding) of the sha256 of the bytes: 52
|
|
40
|
+
* characters, shorter than hex and safe in URLs and on case-insensitive
|
|
41
|
+
* filesystems.
|
|
42
|
+
*/
|
|
43
|
+
export function base32Sha256(bytes) {
|
|
44
|
+
const bits = Array.from(sha256(bytes), byte => byte.toString(2).padStart(8, '0')).join('');
|
|
45
|
+
let out = '';
|
|
46
|
+
for (let i = 0; i < bits.length; i += 5) {
|
|
47
|
+
out += BASE32[Number.parseInt(bits.slice(i, i + 5).padEnd(5, '0'), 2)];
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The hash of a keyset: the base32 sha256 of its canonical JSON, the same
|
|
53
|
+
* however its keys are ordered.
|
|
54
|
+
*/
|
|
55
|
+
export function generateKeysetCID(keyset) {
|
|
56
|
+
// An empty keyset would hash to one key shared by every record that has one.
|
|
57
|
+
if (Object.keys(keyset).length === 0) {
|
|
58
|
+
throw new Error('Cannot compute a content key for an empty object — it carries no identity.');
|
|
59
|
+
}
|
|
60
|
+
return base32Sha256(new TextEncoder().encode(canonicalJson(keyset)));
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The keyset a node's `@key` names: each field's value by its path, with dots
|
|
64
|
+
* and `[*]` splats followed into nested values and a list joined with commas.
|
|
65
|
+
* `source` is the source given, and an `action.` field is read from the action
|
|
66
|
+
* the node is in. A computed entry `{ key, value }` is `key:value`. Fields
|
|
67
|
+
* with no value are left out, and a keyset with none is null, so it never
|
|
68
|
+
* names one node shared by unrelated records. A key of nothing but `url` is
|
|
69
|
+
* the `url` source's, so it names the same node whichever source saw it.
|
|
70
|
+
*/
|
|
71
|
+
export function keysetOf(node, source, actionContext) {
|
|
72
|
+
const keyFields = node['@key'];
|
|
73
|
+
if (!keyFields || keyFields.length === 0)
|
|
74
|
+
return null;
|
|
75
|
+
const keyset = {};
|
|
76
|
+
for (const field of keyFields) {
|
|
77
|
+
if (typeof field === 'string') {
|
|
78
|
+
if (field.startsWith('action.') && actionContext) {
|
|
79
|
+
const value = extractNestedValue(actionContext, field.slice(7));
|
|
80
|
+
if (value !== undefined && value !== null)
|
|
81
|
+
keyset[field] = value;
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
const value = field === 'source' ? source : extractNestedValue(node, field);
|
|
85
|
+
if (value !== undefined && value !== null) {
|
|
86
|
+
keyset[field] = Array.isArray(value) ? value.join(',') : value;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else if (typeof field === 'object' && field.key && field.value !== undefined) {
|
|
91
|
+
keyset[`${field.key}:${field.value}`] = field.value;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (Object.keys(keyset).length === 0)
|
|
95
|
+
return null;
|
|
96
|
+
if (Object.keys(keyset).length === 1 && typeof keyset.url === 'string') {
|
|
97
|
+
return { source: URL_SOURCE, url: keyset.url };
|
|
98
|
+
}
|
|
99
|
+
return keyset;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* A node's identity: the hash of the keyset its `@key` names, or its `@id`,
|
|
103
|
+
* which is that hash already. With both, they must agree. With neither, it's
|
|
104
|
+
* its `url`, as the `url` source's, or else its `attachmentCid`, the stored
|
|
105
|
+
* file it is. `source` is the node's own unless given, as for a node in a
|
|
106
|
+
* record of that source that names none.
|
|
107
|
+
*/
|
|
108
|
+
export function nodeKey(node, source = fieldOf(node, 'source'), actionContext) {
|
|
109
|
+
const keyset = keysetOf(node, source, actionContext);
|
|
110
|
+
const derived = keyset ? generateKeysetCID(keyset) : undefined;
|
|
111
|
+
const id = typeof node['@id'] === 'string' && node['@id'] ? node['@id'] : undefined;
|
|
112
|
+
if (id && derived && id !== derived) {
|
|
113
|
+
throw new Error(`Entity @id (${id}) does not match the hash of its @key keyset (${derived}). ` +
|
|
114
|
+
`Entity type: ${String(node['@type'] ?? 'unknown')}, source: ${source}`);
|
|
115
|
+
}
|
|
116
|
+
const fallback = (field, keyset) => {
|
|
117
|
+
const value = fieldOf(node, field);
|
|
118
|
+
return value ? generateKeysetCID(keyset(value)) : undefined;
|
|
119
|
+
};
|
|
120
|
+
const key = id ??
|
|
121
|
+
derived ??
|
|
122
|
+
fallback('url', url => ({ source: URL_SOURCE, url })) ??
|
|
123
|
+
fallback('attachmentCid', attachmentCid => ({ attachmentCid }));
|
|
124
|
+
if (!key) {
|
|
125
|
+
throw new Error(`Entity carries neither @key nor @id — no usable identity. Entity type: ${String(node['@type'] ?? 'unknown')}, source: ${source}`);
|
|
126
|
+
}
|
|
127
|
+
return key;
|
|
128
|
+
}
|
|
129
|
+
/** A node's field, when it's a string. */
|
|
130
|
+
function fieldOf(node, field) {
|
|
131
|
+
const value = node[field];
|
|
132
|
+
return typeof value === 'string' ? value : undefined;
|
|
133
|
+
}
|
|
134
|
+
function extractNestedValue(obj, path) {
|
|
135
|
+
if (!obj)
|
|
136
|
+
return undefined;
|
|
137
|
+
const parts = path.split('.');
|
|
138
|
+
let current = obj;
|
|
139
|
+
for (let i = 0; i < parts.length; i++) {
|
|
140
|
+
const part = parts[i];
|
|
141
|
+
if (part.includes('[*]')) {
|
|
142
|
+
const arrayProp = part.replace('[*]', '');
|
|
143
|
+
if (current && typeof current === 'object' && arrayProp in current) {
|
|
144
|
+
const arrayValue = current[arrayProp];
|
|
145
|
+
if (Array.isArray(arrayValue)) {
|
|
146
|
+
const remainingPath = parts.slice(i + 1).join('.');
|
|
147
|
+
if (remainingPath) {
|
|
148
|
+
return arrayValue
|
|
149
|
+
.map(item => extractNestedValue(item, remainingPath))
|
|
150
|
+
.filter(val => val !== undefined);
|
|
151
|
+
}
|
|
152
|
+
return arrayValue;
|
|
153
|
+
}
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
if (current && typeof current === 'object' && part in current) {
|
|
159
|
+
current = current[part];
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return current;
|
|
166
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED