dacument 1.0.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.
@@ -0,0 +1,16 @@
1
+ export type HLCStamp = {
2
+ /** Unix ms */
3
+ readonly wallTimeMs: number;
4
+ /** logical counter for same/older wallTimeMs */
5
+ readonly logical: number;
6
+ /** stable actor/node id for deterministic tie-break */
7
+ readonly clockId: string;
8
+ };
9
+ export declare function compareHLC(left: HLCStamp, right: HLCStamp): number;
10
+ export declare class HLC {
11
+ private last;
12
+ constructor(clockId: string);
13
+ next(nowMs?: number): HLCStamp;
14
+ observe(stamp: HLCStamp): void;
15
+ get current(): HLCStamp;
16
+ }
@@ -0,0 +1,38 @@
1
+ export function compareHLC(left, right) {
2
+ if (left.wallTimeMs !== right.wallTimeMs)
3
+ return left.wallTimeMs - right.wallTimeMs;
4
+ if (left.logical !== right.logical)
5
+ return left.logical - right.logical;
6
+ if (left.clockId === right.clockId)
7
+ return 0;
8
+ return left.clockId < right.clockId ? -1 : 1;
9
+ }
10
+ export class HLC {
11
+ last;
12
+ constructor(clockId) {
13
+ this.last = { wallTimeMs: 0, logical: 0, clockId };
14
+ }
15
+ next(nowMs = Date.now()) {
16
+ const wallTimeMs = Math.max(nowMs, this.last.wallTimeMs);
17
+ const logical = wallTimeMs === this.last.wallTimeMs ? this.last.logical + 1 : 0;
18
+ const next = { wallTimeMs, logical, clockId: this.last.clockId };
19
+ this.last = next;
20
+ return next;
21
+ }
22
+ observe(stamp) {
23
+ const mergedWall = Math.max(this.last.wallTimeMs, stamp.wallTimeMs, Date.now());
24
+ const mergedLogical = mergedWall === this.last.wallTimeMs
25
+ ? Math.max(this.last.logical, stamp.logical) + 1
26
+ : mergedWall === stamp.wallTimeMs
27
+ ? stamp.logical
28
+ : 0;
29
+ this.last = {
30
+ wallTimeMs: mergedWall,
31
+ logical: mergedLogical,
32
+ clockId: this.last.clockId,
33
+ };
34
+ }
35
+ get current() {
36
+ return this.last;
37
+ }
38
+ }
@@ -0,0 +1,26 @@
1
+ type SignedHeader = {
2
+ alg: "ES256";
3
+ typ: string;
4
+ kid?: string;
5
+ };
6
+ type UnsignedHeader = {
7
+ alg: "none";
8
+ typ: string;
9
+ kid?: string;
10
+ };
11
+ type Header = SignedHeader | UnsignedHeader;
12
+ type DecodedToken = {
13
+ header: Header;
14
+ payload: unknown;
15
+ signature: Uint8Array;
16
+ headerB64: string;
17
+ payloadB64: string;
18
+ };
19
+ export declare function signToken(privateJwk: JsonWebKey, header: SignedHeader, payload: unknown): Promise<string>;
20
+ export declare function encodeToken(header: UnsignedHeader, payload: unknown): string;
21
+ export declare function decodeToken(token: string): DecodedToken | null;
22
+ export declare function verifyToken(publicJwk: JsonWebKey, token: string, expectedTyp: string): Promise<{
23
+ header: SignedHeader;
24
+ payload: unknown;
25
+ } | false>;
26
+ export {};
@@ -0,0 +1,64 @@
1
+ import { Bytes } from "bytecodec";
2
+ import { SigningAgent, VerificationAgent } from "zeyra";
3
+ function stableStringify(value) {
4
+ if (value === null || typeof value !== "object")
5
+ return JSON.stringify(value);
6
+ if (Array.isArray(value))
7
+ return `[${value.map((item) => stableStringify(item)).join(",")}]`;
8
+ const entries = Object.entries(value).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
9
+ const body = entries
10
+ .map(([key, val]) => `${JSON.stringify(key)}:${stableStringify(val)}`)
11
+ .join(",");
12
+ return `{${body}}`;
13
+ }
14
+ function decodePart(part) {
15
+ const bytes = Bytes.fromBase64UrlString(part);
16
+ const json = Bytes.toString(bytes);
17
+ return JSON.parse(json);
18
+ }
19
+ export async function signToken(privateJwk, header, payload) {
20
+ const headerJson = stableStringify(header);
21
+ const payloadJson = stableStringify(payload);
22
+ const headerB64 = Bytes.toBase64UrlString(Bytes.fromString(headerJson));
23
+ const payloadB64 = Bytes.toBase64UrlString(Bytes.fromString(payloadJson));
24
+ const signingInput = `${headerB64}.${payloadB64}`;
25
+ const signer = new SigningAgent(privateJwk);
26
+ const signature = await signer.sign(Bytes.fromString(signingInput));
27
+ const signatureB64 = Bytes.toBase64UrlString(signature);
28
+ return `${signingInput}.${signatureB64}`;
29
+ }
30
+ export function encodeToken(header, payload) {
31
+ const headerJson = stableStringify(header);
32
+ const payloadJson = stableStringify(payload);
33
+ const headerB64 = Bytes.toBase64UrlString(Bytes.fromString(headerJson));
34
+ const payloadB64 = Bytes.toBase64UrlString(Bytes.fromString(payloadJson));
35
+ return `${headerB64}.${payloadB64}.`;
36
+ }
37
+ export function decodeToken(token) {
38
+ const parts = token.split(".");
39
+ if (parts.length !== 3)
40
+ return null;
41
+ const [headerB64, payloadB64, signatureB64] = parts;
42
+ try {
43
+ const header = decodePart(headerB64);
44
+ const payload = decodePart(payloadB64);
45
+ const signature = Bytes.fromBase64UrlString(signatureB64);
46
+ return { header, payload, signature, headerB64, payloadB64 };
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ export async function verifyToken(publicJwk, token, expectedTyp) {
53
+ const decoded = decodeToken(token);
54
+ if (!decoded)
55
+ return false;
56
+ const { header, payload, signature, headerB64, payloadB64 } = decoded;
57
+ if (header.alg !== "ES256" || header.typ !== expectedTyp)
58
+ return false;
59
+ const verifier = new VerificationAgent(publicJwk);
60
+ const signingInput = Bytes.fromString(`${headerB64}.${payloadB64}`);
61
+ const signatureBytes = new Uint8Array(signature);
62
+ const ok = await verifier.verify(signingInput, signatureBytes.buffer);
63
+ return ok ? { header, payload } : false;
64
+ }
@@ -0,0 +1,212 @@
1
+ import type { HLCStamp } from "./clock.js";
2
+ export type Role = "owner" | "manager" | "editor" | "viewer" | "revoked";
3
+ export type CRType = "register" | "text" | "array" | "map" | "set" | "record";
4
+ export type JsValue = string | number | boolean | null | JsValue[] | {
5
+ [key: string]: JsValue;
6
+ };
7
+ export type JsTypeName = "string" | "number" | "boolean" | "json" | "any";
8
+ export type JsTypeMap = {
9
+ string: string;
10
+ number: number;
11
+ boolean: boolean;
12
+ json: JsValue;
13
+ any: unknown;
14
+ };
15
+ export type JsTypeValue<T extends JsTypeName> = JsTypeMap[T];
16
+ export type RoleKeyPair = {
17
+ publicKey: JsonWebKey;
18
+ privateKey: JsonWebKey;
19
+ };
20
+ export type RoleKeys = {
21
+ owner: RoleKeyPair;
22
+ manager: RoleKeyPair;
23
+ editor: RoleKeyPair;
24
+ };
25
+ export type RolePublicKeys = {
26
+ owner: JsonWebKey;
27
+ manager: JsonWebKey;
28
+ editor: JsonWebKey;
29
+ };
30
+ export type RegisterSchema<T extends JsTypeName = JsTypeName> = {
31
+ crdt: "register";
32
+ jsType: T;
33
+ regex?: RegExp;
34
+ initial?: JsTypeValue<T>;
35
+ };
36
+ export type TextSchema = {
37
+ crdt: "text";
38
+ jsType: "string";
39
+ initial?: string;
40
+ };
41
+ export type ArraySchema<T extends JsTypeName = JsTypeName> = {
42
+ crdt: "array";
43
+ jsType: T;
44
+ initial?: JsTypeValue<T>[];
45
+ key?: (value: JsTypeValue<T>) => string;
46
+ };
47
+ export type SetSchema<T extends JsTypeName = JsTypeName> = {
48
+ crdt: "set";
49
+ jsType: T;
50
+ initial?: JsTypeValue<T>[];
51
+ key?: (value: JsTypeValue<T>) => string;
52
+ };
53
+ export type MapSchema<T extends JsTypeName = JsTypeName> = {
54
+ crdt: "map";
55
+ jsType: T;
56
+ initial?: Array<[unknown, JsTypeValue<T>]>;
57
+ key?: (value: unknown) => string;
58
+ };
59
+ export type RecordSchema<T extends JsTypeName = JsTypeName> = {
60
+ crdt: "record";
61
+ jsType: T;
62
+ initial?: Record<string, JsTypeValue<T>>;
63
+ };
64
+ export type FieldSchema = RegisterSchema | TextSchema | ArraySchema | SetSchema | MapSchema | RecordSchema;
65
+ export type SchemaDefinition = Record<string, FieldSchema>;
66
+ export type SchemaId = string;
67
+ export type OpKind = "acl.set" | "register.set" | "text.patch" | "array.patch" | "map.patch" | "set.patch" | "record.patch" | "ack";
68
+ export type OpPayload = {
69
+ iss: string;
70
+ sub: string;
71
+ iat: number;
72
+ stamp: HLCStamp;
73
+ kind: OpKind;
74
+ schema: SchemaId;
75
+ field?: string;
76
+ patch?: unknown;
77
+ };
78
+ export type SignedOp = {
79
+ token: string;
80
+ };
81
+ export type DacumentChangeEvent = {
82
+ type: "change";
83
+ ops: SignedOp[];
84
+ };
85
+ export type DacumentMergeEvent = {
86
+ type: "merge";
87
+ actor: string;
88
+ target: string;
89
+ method: string;
90
+ data: unknown;
91
+ };
92
+ export type DacumentErrorEvent = {
93
+ type: "error";
94
+ error: Error;
95
+ };
96
+ export type DacumentRevokedEvent = {
97
+ type: "revoked";
98
+ actorId: string;
99
+ previous: Role;
100
+ by: string;
101
+ stamp: HLCStamp;
102
+ };
103
+ export type DacumentEventMap = {
104
+ change: DacumentChangeEvent;
105
+ merge: DacumentMergeEvent;
106
+ error: DacumentErrorEvent;
107
+ revoked: DacumentRevokedEvent;
108
+ };
109
+ export type AclAssignment = {
110
+ id: string;
111
+ actorId: string;
112
+ role: Role;
113
+ stamp: HLCStamp;
114
+ by: string;
115
+ };
116
+ export type DocSnapshot = {
117
+ docId: string;
118
+ roleKeys: RolePublicKeys;
119
+ ops: SignedOp[];
120
+ };
121
+ export type TextView = {
122
+ length: number;
123
+ toString(): string;
124
+ at(index: number): string | undefined;
125
+ insertAt(index: number, value: string): unknown;
126
+ deleteAt(index: number): string | undefined;
127
+ [Symbol.iterator](): Iterator<string>;
128
+ };
129
+ export type ArrayView<T> = {
130
+ length: number;
131
+ at(index: number): T | undefined;
132
+ slice(start?: number, end?: number): T[];
133
+ push(...items: T[]): number;
134
+ unshift(...items: T[]): number;
135
+ pop(): T | undefined;
136
+ shift(): T | undefined;
137
+ setAt(index: number, value: T): unknown;
138
+ map<U>(callback: (value: T, index: number, array: T[]) => U, thisArg?: unknown): U[];
139
+ filter(callback: (value: T, index: number, array: T[]) => boolean, thisArg?: unknown): T[];
140
+ reduce<U>(reducer: (prev: U, curr: T, index: number, array: T[]) => U, initialValue: U): U;
141
+ forEach(callback: (value: T, index: number, array: T[]) => void, thisArg?: unknown): void;
142
+ includes(value: T): boolean;
143
+ indexOf(value: T): number;
144
+ [Symbol.iterator](): Iterator<T>;
145
+ };
146
+ export type SetView<T> = {
147
+ size: number;
148
+ add(value: T): unknown;
149
+ delete(value: T): boolean;
150
+ clear(): void;
151
+ has(value: T): boolean;
152
+ entries(): SetIterator<[T, T]>;
153
+ keys(): SetIterator<T>;
154
+ values(): SetIterator<T>;
155
+ forEach(callback: (value: T, value2: T, set: Set<T>) => void, thisArg?: unknown): void;
156
+ [Symbol.iterator](): SetIterator<T>;
157
+ [Symbol.toStringTag]: string;
158
+ };
159
+ export type MapView<V> = {
160
+ size: number;
161
+ get(key: unknown): V | undefined;
162
+ set(key: unknown, value: V): unknown;
163
+ has(key: unknown): boolean;
164
+ delete(key: unknown): boolean;
165
+ clear(): void;
166
+ entries(): MapIterator<[unknown, V]>;
167
+ keys(): MapIterator<unknown>;
168
+ values(): MapIterator<V>;
169
+ forEach(callback: (value: V, key: unknown, map: Map<unknown, V>) => void, thisArg?: unknown): void;
170
+ [Symbol.iterator](): MapIterator<[unknown, V]>;
171
+ [Symbol.toStringTag]: string;
172
+ };
173
+ export type RecordView<T> = Record<string, T> & {};
174
+ export type FieldValue<F extends FieldSchema> = F["crdt"] extends "register" ? JsTypeValue<F["jsType"]> : F["crdt"] extends "text" ? TextView : F["crdt"] extends "array" ? ArrayView<JsTypeValue<F["jsType"]>> : F["crdt"] extends "set" ? SetView<JsTypeValue<F["jsType"]>> : F["crdt"] extends "map" ? MapView<JsTypeValue<F["jsType"]>> : F["crdt"] extends "record" ? RecordView<JsTypeValue<F["jsType"]>> : never;
175
+ export type DocFieldAccess<S extends SchemaDefinition> = {
176
+ [K in keyof S]: FieldValue<S[K]>;
177
+ };
178
+ export declare function isJsValue(value: unknown): value is JsValue;
179
+ export declare function isValueOfType(value: unknown, jsType: JsTypeName): boolean;
180
+ export type SchemaIdInput = Record<string, {
181
+ crdt: CRType;
182
+ jsType: JsTypeName;
183
+ regex?: string;
184
+ }>;
185
+ export declare function schemaIdInput(schema: SchemaDefinition): SchemaIdInput;
186
+ export declare function register<T extends JsTypeName = "any">(options?: {
187
+ jsType?: T;
188
+ regex?: RegExp;
189
+ initial?: JsTypeValue<T>;
190
+ }): RegisterSchema<T>;
191
+ export declare function text(options?: {
192
+ initial?: string;
193
+ }): TextSchema;
194
+ export declare function array<T extends JsTypeName>(options: {
195
+ jsType: T;
196
+ initial?: JsTypeValue<T>[];
197
+ key?: (value: JsTypeValue<T>) => string;
198
+ }): ArraySchema<T>;
199
+ export declare function set<T extends JsTypeName>(options: {
200
+ jsType: T;
201
+ initial?: JsTypeValue<T>[];
202
+ key?: (value: JsTypeValue<T>) => string;
203
+ }): SetSchema<T>;
204
+ export declare function map<T extends JsTypeName>(options: {
205
+ jsType: T;
206
+ initial?: Array<[unknown, JsTypeValue<T>]>;
207
+ key?: (value: unknown) => string;
208
+ }): MapSchema<T>;
209
+ export declare function record<T extends JsTypeName>(options: {
210
+ jsType: T;
211
+ initial?: Record<string, JsTypeValue<T>>;
212
+ }): RecordSchema<T>;
@@ -0,0 +1,79 @@
1
+ export function isJsValue(value) {
2
+ if (value === null)
3
+ return true;
4
+ const valueType = typeof value;
5
+ if (valueType === "string" || valueType === "number" || valueType === "boolean")
6
+ return true;
7
+ if (Array.isArray(value))
8
+ return value.every(isJsValue);
9
+ if (valueType === "object") {
10
+ for (const entry of Object.values(value)) {
11
+ if (!isJsValue(entry))
12
+ return false;
13
+ }
14
+ return true;
15
+ }
16
+ return false;
17
+ }
18
+ export function isValueOfType(value, jsType) {
19
+ if (jsType === "any")
20
+ return true;
21
+ if (jsType === "json")
22
+ return isJsValue(value);
23
+ return typeof value === jsType;
24
+ }
25
+ export function schemaIdInput(schema) {
26
+ const normalized = {};
27
+ for (const [key, field] of Object.entries(schema)) {
28
+ normalized[key] = {
29
+ crdt: field.crdt,
30
+ jsType: field.jsType,
31
+ regex: field.crdt === "register" && field.regex
32
+ ? field.regex.source + "/" + field.regex.flags
33
+ : undefined,
34
+ };
35
+ }
36
+ return normalized;
37
+ }
38
+ export function register(options = {}) {
39
+ return {
40
+ crdt: "register",
41
+ jsType: options.jsType ?? "any",
42
+ regex: options.regex,
43
+ initial: options.initial,
44
+ };
45
+ }
46
+ export function text(options = {}) {
47
+ return { crdt: "text", jsType: "string", initial: options.initial ?? "" };
48
+ }
49
+ export function array(options) {
50
+ return {
51
+ crdt: "array",
52
+ jsType: options.jsType,
53
+ initial: options.initial ?? [],
54
+ key: options.key,
55
+ };
56
+ }
57
+ export function set(options) {
58
+ return {
59
+ crdt: "set",
60
+ jsType: options.jsType,
61
+ initial: options.initial ?? [],
62
+ key: options.key,
63
+ };
64
+ }
65
+ export function map(options) {
66
+ return {
67
+ crdt: "map",
68
+ jsType: options.jsType,
69
+ initial: options.initial ?? [],
70
+ key: options.key,
71
+ };
72
+ }
73
+ export function record(options) {
74
+ return {
75
+ crdt: "record",
76
+ jsType: options.jsType,
77
+ initial: options.initial ?? {},
78
+ };
79
+ }
@@ -0,0 +1,10 @@
1
+ import { CRArray } from "./CRArray/class.js";
2
+ import { CRMap } from "./CRMap/class.js";
3
+ import { CRRecord } from "./CRRecord/class.js";
4
+ import { CRRegister } from "./CRRegister/class.js";
5
+ import { CRSet } from "./CRSet/class.js";
6
+ import { CRText } from "./CRText/class.js";
7
+ import { Dacument } from "./Dacument/class.js";
8
+ export { CRArray, CRMap, CRRecord, CRRegister, CRSet, CRText, Dacument };
9
+ export * from "./Dacument/types.js";
10
+ export type { DacumentDoc } from "./Dacument/class.js";
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ import { CRArray } from "./CRArray/class.js";
2
+ import { CRMap } from "./CRMap/class.js";
3
+ import { CRRecord } from "./CRRecord/class.js";
4
+ import { CRRegister } from "./CRRegister/class.js";
5
+ import { CRSet } from "./CRSet/class.js";
6
+ import { CRText } from "./CRText/class.js";
7
+ import { Dacument } from "./Dacument/class.js";
8
+ export { CRArray, CRMap, CRRecord, CRRegister, CRSet, CRText, Dacument };
9
+ export * from "./Dacument/types.js";
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "dacument",
3
+ "version": "1.0.0",
4
+ "description": "Schema-driven CRDT document with signed ops and role-based ACLs.",
5
+ "keywords": [
6
+ "crdt",
7
+ "document",
8
+ "collaboration",
9
+ "replication",
10
+ "sync",
11
+ "acl",
12
+ "signature",
13
+ "crypto",
14
+ "eventual-consistency",
15
+ "offline"
16
+ ],
17
+ "license": "MIT",
18
+ "type": "module",
19
+ "scripts": {
20
+ "build": "tsc -p tsconfig.json",
21
+ "test": "npm run build && node --test test/*.test.js",
22
+ "bench": "npm run build && node bench/index.js",
23
+ "sim": "npm run build && node bench/dacument.sim.js",
24
+ "verify": "npm run test && npm run bench && npm run sim"
25
+ },
26
+ "main": "dist/index.js",
27
+ "types": "dist/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "import": "./dist/index.js",
31
+ "types": "./dist/index.d.ts"
32
+ },
33
+ "./package.json": "./package.json"
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "README.md",
38
+ "LICENSE"
39
+ ],
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/jortsupetterson/dacument.git"
43
+ },
44
+ "bugs": {
45
+ "url": "https://github.com/jortsupetterson/dacument/issues"
46
+ },
47
+ "homepage": "https://github.com/jortsupetterson/dacument#readme",
48
+ "engines": {
49
+ "node": ">=18"
50
+ },
51
+ "sideEffects": false,
52
+ "dependencies": {
53
+ "bytecodec": "^2.1.0",
54
+ "uuid": "^13.0.0",
55
+ "zeyra": "^2.1.0"
56
+ },
57
+ "devDependencies": {
58
+ "@types/node": "^25.0.3",
59
+ "typescript": "^5.9.3"
60
+ }
61
+ }