@dxos/keys 0.8.4-staging.ac66bdf99f → 0.9.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.
Files changed (54) hide show
  1. package/LICENSE +102 -5
  2. package/dist/lib/browser/index.mjs +294 -435
  3. package/dist/lib/browser/index.mjs.map +4 -4
  4. package/dist/lib/browser/meta.json +1 -1
  5. package/dist/lib/node-esm/index.mjs +289 -433
  6. package/dist/lib/node-esm/index.mjs.map +4 -4
  7. package/dist/lib/node-esm/meta.json +1 -1
  8. package/dist/types/src/DXN.d.ts +50 -0
  9. package/dist/types/src/DXN.d.ts.map +1 -0
  10. package/dist/types/src/DXN.test.d.ts +2 -0
  11. package/dist/types/src/DXN.test.d.ts.map +1 -0
  12. package/dist/types/src/EID.d.ts +74 -0
  13. package/dist/types/src/EID.d.ts.map +1 -0
  14. package/dist/types/src/EID.test.d.ts +2 -0
  15. package/dist/types/src/EID.test.d.ts.map +1 -0
  16. package/dist/types/src/URI.d.ts +23 -0
  17. package/dist/types/src/URI.d.ts.map +1 -0
  18. package/dist/types/src/entity-id.d.ts +104 -0
  19. package/dist/types/src/entity-id.d.ts.map +1 -0
  20. package/dist/types/src/entity-id.test.d.ts +2 -0
  21. package/dist/types/src/entity-id.test.d.ts.map +1 -0
  22. package/dist/types/src/identity-did.d.ts +6 -4
  23. package/dist/types/src/identity-did.d.ts.map +1 -1
  24. package/dist/types/src/index.d.ts +5 -2
  25. package/dist/types/src/index.d.ts.map +1 -1
  26. package/dist/types/src/parse-id.d.ts +10 -0
  27. package/dist/types/src/parse-id.d.ts.map +1 -0
  28. package/dist/types/src/parse-id.test.d.ts +2 -0
  29. package/dist/types/src/parse-id.test.d.ts.map +1 -0
  30. package/dist/types/src/prng.d.ts.map +1 -1
  31. package/dist/types/src/public-key.d.ts +1 -1
  32. package/dist/types/src/public-key.d.ts.map +1 -1
  33. package/dist/types/src/random-bytes.d.ts.map +1 -1
  34. package/dist/types/tsconfig.tsbuildinfo +1 -1
  35. package/package.json +7 -10
  36. package/src/DXN.test.ts +85 -0
  37. package/src/DXN.ts +104 -0
  38. package/src/EID.test.ts +147 -0
  39. package/src/EID.ts +151 -0
  40. package/src/URI.ts +35 -0
  41. package/src/entity-id.test.ts +73 -0
  42. package/src/entity-id.ts +202 -0
  43. package/src/identity-did.test.ts +19 -2
  44. package/src/identity-did.ts +60 -25
  45. package/src/index.ts +6 -2
  46. package/src/parse-id.test.ts +32 -0
  47. package/src/parse-id.ts +32 -0
  48. package/src/public-key.ts +3 -3
  49. package/dist/types/src/dxn.d.ts +0 -129
  50. package/dist/types/src/dxn.d.ts.map +0 -1
  51. package/dist/types/src/object-id.d.ts +0 -65
  52. package/dist/types/src/object-id.d.ts.map +0 -1
  53. package/src/dxn.ts +0 -345
  54. package/src/object-id.ts +0 -131
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/keys",
3
- "version": "0.8.4-staging.ac66bdf99f",
3
+ "version": "0.9.0",
4
4
  "description": "Key utils and definitions.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -8,7 +8,7 @@
8
8
  "type": "git",
9
9
  "url": "https://github.com/dxos/dxos"
10
10
  },
11
- "license": "MIT",
11
+ "license": "FSL-1.1-Apache-2.0",
12
12
  "author": "DXOS.org",
13
13
  "sideEffects": false,
14
14
  "type": "module",
@@ -21,26 +21,23 @@
21
21
  }
22
22
  },
23
23
  "types": "dist/types/src/index.d.ts",
24
- "typesVersions": {
25
- "*": {}
26
- },
27
24
  "files": [
28
25
  "dist",
29
26
  "src"
30
27
  ],
31
28
  "dependencies": {
32
29
  "ulidx": "^2.3.0",
33
- "@dxos/invariant": "0.8.4-staging.ac66bdf99f",
34
- "@dxos/debug": "0.8.4-staging.ac66bdf99f",
35
- "@dxos/node-std": "0.8.4-staging.ac66bdf99f"
30
+ "@dxos/debug": "0.9.0",
31
+ "@dxos/node-std": "0.9.0",
32
+ "@dxos/invariant": "0.9.0"
36
33
  },
37
34
  "devDependencies": {
38
35
  "base32-decode": "^1.0.0",
39
36
  "base32-encode": "^2.0.0",
40
- "effect": "3.20.0"
37
+ "effect": "3.21.3"
41
38
  },
42
39
  "peerDependencies": {
43
- "effect": "3.20.0"
40
+ "effect": "3.21.3"
44
41
  },
45
42
  "publishConfig": {
46
43
  "access": "public"
@@ -0,0 +1,85 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import * as DXN from './DXN';
8
+
9
+ describe('DXN.isDXN', () => {
10
+ test('accepts new-format DXNs', ({ expect }) => {
11
+ expect(DXN.isDXN('dxn:org.dxos.type.calendar')).toBe(true);
12
+ expect(DXN.isDXN('dxn:org.dxos.type.calendar:1.0.0')).toBe(true);
13
+ expect(DXN.isDXN('dxn:com.alice.type.contact:2.1.0')).toBe(true);
14
+ expect(DXN.isDXN('dxn:org.dxos.plugin.markdown')).toBe(true);
15
+ expect(DXN.isDXN('dxn:org.dxos.type.calendarEvent')).toBe(true);
16
+ });
17
+
18
+ test('rejects non-DXN strings', ({ expect }) => {
19
+ expect(DXN.isDXN('echo://space/object')).toBe(false);
20
+ expect(DXN.isDXN('https://example.com')).toBe(false);
21
+ expect(DXN.isDXN('')).toBe(false);
22
+ expect(DXN.isDXN(42)).toBe(false);
23
+ });
24
+ });
25
+
26
+ describe('DXN.make', () => {
27
+ test('produces unversioned DXN', ({ expect }) => {
28
+ expect(DXN.make('org.dxos.type.calendar')).toBe('dxn:org.dxos.type.calendar');
29
+ });
30
+
31
+ test('produces versioned DXN', ({ expect }) => {
32
+ expect(DXN.make('org.dxos.type.calendar', '1.0.0')).toBe('dxn:org.dxos.type.calendar:1.0.0');
33
+ });
34
+
35
+ test('throws on invalid NSID', ({ expect }) => {
36
+ expect(() => DXN.make('not-a-valid-nsid')).toThrow();
37
+ expect(() => DXN.make('com.example.type.registry-entry')).toThrow();
38
+ expect(() => DXN.make('com.example.type.registry-entry', '0.1.0')).toThrow();
39
+ });
40
+ });
41
+
42
+ describe('DXN.tryMake', () => {
43
+ test('parses new-format DXN strings', ({ expect }) => {
44
+ expect(DXN.tryMake('dxn:org.dxos.type.calendar')).toBe('dxn:org.dxos.type.calendar');
45
+ expect(DXN.tryMake('dxn:org.dxos.type.calendar:1.0.0')).toBe('dxn:org.dxos.type.calendar:1.0.0');
46
+ });
47
+
48
+ test('returns undefined on invalid input', ({ expect }) => {
49
+ expect(DXN.tryMake('not-a-dxn')).toBeUndefined();
50
+ expect(DXN.tryMake('dxn:invalid')).toBeUndefined();
51
+ });
52
+
53
+ test('rejects hyphens in the last NSID segment (must be camelCase)', ({ expect }) => {
54
+ expect(DXN.tryMake('dxn:com.example.type.registry-entry')).toBeUndefined();
55
+ expect(DXN.tryMake('dxn:com.example.type.registry-entry:0.1.0')).toBeUndefined();
56
+ });
57
+
58
+ test('accepts hyphens in middle segments but not the last', ({ expect }) => {
59
+ expect(DXN.tryMake('dxn:org.dxos.relation.plugin-crm.profileOf')).toBe(
60
+ 'dxn:org.dxos.relation.plugin-crm.profileOf',
61
+ );
62
+ });
63
+ });
64
+
65
+ describe('DXN.getName', () => {
66
+ test('extracts NSID from new-format DXN', ({ expect }) => {
67
+ expect(DXN.getName(DXN.make('org.dxos.type.calendar'))).toBe('org.dxos.type.calendar');
68
+ expect(DXN.getName(DXN.make('org.dxos.plugin.markdown'))).toBe('org.dxos.plugin.markdown');
69
+ });
70
+
71
+ test('extracts NSID from versioned DXN (without version)', ({ expect }) => {
72
+ expect(DXN.getName(DXN.make('org.dxos.type.calendar', '1.0.0'))).toBe('org.dxos.type.calendar');
73
+ });
74
+ });
75
+
76
+ describe('DXN.getVersion', () => {
77
+ test('returns version from versioned DXN', ({ expect }) => {
78
+ expect(DXN.getVersion(DXN.make('org.dxos.type.calendar', '1.0.0'))).toBe('1.0.0');
79
+ expect(DXN.getVersion(DXN.make('com.alice.type.contact', '2.1.0'))).toBe('2.1.0');
80
+ });
81
+
82
+ test('returns undefined for unversioned DXN', ({ expect }) => {
83
+ expect(DXN.getVersion(DXN.make('org.dxos.type.calendar'))).toBeUndefined();
84
+ });
85
+ });
package/src/DXN.ts ADDED
@@ -0,0 +1,104 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ // @import-as-namespace
6
+
7
+ import * as Schema from 'effect/Schema';
8
+
9
+ import type * as URI from './URI';
10
+
11
+ /**
12
+ * Full DXN regex per spec: `dxn:<nsid>[:<version>]`.
13
+ * Middle segments may contain hyphens; the final segment must be camelCase
14
+ * (alphanumeric, leading letter — no hyphens or underscores).
15
+ */
16
+ const DXN_SPEC_REGEXP =
17
+ /^dxn:[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(\.[a-zA-Z][a-zA-Z0-9]{0,62})(:\d+\.\d+\.\d+)?$/;
18
+
19
+ /**
20
+ * DXN names a resource (type, plugin, capability, etc.).
21
+ *
22
+ * Format: `dxn:<nsid>[:<version>]` where NSID is an atproto-style dotted name.
23
+ *
24
+ * @example
25
+ * ```
26
+ * dxn:org.dxos.type.calendar
27
+ * dxn:org.dxos.type.calendar:1.0.0
28
+ * dxn:org.dxos.plugin.markdown
29
+ * ```
30
+ */
31
+ export type DXN = URI.URI & { readonly __DXN: unique symbol };
32
+
33
+ /**
34
+ * Cheap prefix check — does not validate the full DXN grammar.
35
+ * Sufficient for narrowing a URI to a DXN.
36
+ */
37
+ export const isDXN = (value: unknown): value is DXN => typeof value === 'string' && value.startsWith('dxn:');
38
+
39
+ /**
40
+ * Constructs a DXN from an NSID (and optional version). Throws if the result
41
+ * is not a valid DXN. Use `tryMake` for non-throwing string parsing.
42
+ *
43
+ * @example make('org.dxos.type.calendar') → 'dxn:org.dxos.type.calendar'
44
+ * @example make('org.dxos.type.calendar', '1.0.0') → 'dxn:org.dxos.type.calendar:1.0.0'
45
+ */
46
+ export const make = (nsid: string, version?: string): DXN =>
47
+ parse(version != null ? `dxn:${nsid}:${version}` : `dxn:${nsid}`);
48
+
49
+ /**
50
+ * Parses a full DXN string. Returns undefined on failure.
51
+ */
52
+ export const tryMake = (dxn: string): DXN | undefined => {
53
+ try {
54
+ return parse(dxn);
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ };
59
+
60
+ // Internal — full-grammar validator. Callers outside this module should use
61
+ // `make(nsid, version?)` or `tryMake(dxn)`.
62
+ const parse = (dxn: string): DXN => {
63
+ if (typeof dxn === 'string' && DXN_SPEC_REGEXP.test(dxn)) {
64
+ return dxn as DXN;
65
+ }
66
+ throw new Error(`Invalid DXN: ${dxn}`);
67
+ };
68
+
69
+ /**
70
+ * Returns the NSID portion of a DXN (the part after `dxn:` and before optional `:<version>`).
71
+ * @example getName('dxn:org.dxos.type.calendar:1.0.0') → 'org.dxos.type.calendar'
72
+ */
73
+ export const getName = (dxn: DXN): string => {
74
+ const match = /^dxn:([^:]+)/.exec(dxn);
75
+ if (!match) {
76
+ throw new Error(`Invalid DXN: ${dxn}`);
77
+ }
78
+ return match[1];
79
+ };
80
+
81
+ /**
82
+ * Returns the semver version from a versioned DXN, or undefined if unversioned.
83
+ * @example getVersion('dxn:org.dxos.type.calendar:1.0.0') → '1.0.0'
84
+ */
85
+ export const getVersion = (dxn: DXN): string | undefined => {
86
+ const match = /^dxn:[^:]+:(\d+\.\d+\.\d+)$/.exec(dxn);
87
+ return match?.[1];
88
+ };
89
+
90
+ /**
91
+ * Effect Schema for DXN validation.
92
+ */
93
+ // Identity-encoded schema (`Schema<DXN, DXN>`) so consumers can refine generic schemas
94
+ // without the encode/decode types diverging. `Schema.filter` produces a refinement with
95
+ // `Encoded = string`; we narrow the encoded form too with `as unknown as` since the runtime
96
+ // representation is identical (a branded string).
97
+ const Schema_: Schema.Schema<DXN, DXN> = Schema.String.pipe(
98
+ Schema.filter((value): value is DXN => isDXN(value), { message: () => 'Invalid DXN' }),
99
+ Schema.annotations({
100
+ title: 'DXN',
101
+ description: 'DXN URI: dxn:<nsid>[:<version>]',
102
+ }),
103
+ ) as unknown as Schema.Schema<DXN, DXN>;
104
+ export { Schema_ as Schema };
@@ -0,0 +1,147 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import * as EID from './EID';
8
+ import { EntityId } from './entity-id';
9
+ import { SpaceId } from './space-id';
10
+
11
+ const SPACE = SpaceId.random();
12
+ const OBJECT = EntityId.random();
13
+ const OBJECT2 = EntityId.random();
14
+
15
+ describe('EID.make', () => {
16
+ test('produces qualified echo URI from spaceId + objectId', ({ expect }) => {
17
+ expect(EID.make({ spaceId: SPACE, entityId: OBJECT })).toBe(`echo://${SPACE}/${OBJECT}`);
18
+ });
19
+
20
+ test('produces local echo URI from objectId only', ({ expect }) => {
21
+ expect(EID.make({ entityId: OBJECT })).toBe(`echo:/${OBJECT}`);
22
+ });
23
+
24
+ test('produces space-only echo URI from spaceId only', ({ expect }) => {
25
+ expect(EID.make({ spaceId: SPACE })).toBe(`echo://${SPACE}`);
26
+ });
27
+
28
+ test('throws when neither id is provided', ({ expect }) => {
29
+ expect(() => EID.make({})).toThrow();
30
+ });
31
+ });
32
+
33
+ describe('EID.isEID', () => {
34
+ test('accepts new format', ({ expect }) => {
35
+ expect(EID.isEID(`echo://${SPACE}/${OBJECT}`)).toBe(true);
36
+ expect(EID.isEID(`echo:/${OBJECT}`)).toBe(true);
37
+ expect(EID.isEID(`echo:///${OBJECT}`)).toBe(true);
38
+ expect(EID.isEID(`echo://${SPACE}`)).toBe(true);
39
+ });
40
+
41
+ test('rejects non-echo strings', ({ expect }) => {
42
+ expect(EID.isEID('dxn:org.dxos.type.calendar')).toBe(false);
43
+ expect(EID.isEID(`dxn:echo:@:${OBJECT}`)).toBe(false);
44
+ expect(EID.isEID(`dxn:queue:data:${SPACE}:${OBJECT}`)).toBe(false);
45
+ expect(EID.isEID('https://example.com')).toBe(false);
46
+ expect(EID.isEID('')).toBe(false);
47
+ expect(EID.isEID(42)).toBe(false);
48
+ });
49
+ });
50
+
51
+ describe('EID.parse', () => {
52
+ test('passes through canonical format unchanged', ({ expect }) => {
53
+ const id = `echo://${SPACE}/${OBJECT}`;
54
+ expect(EID.parse(id)).toBe(id);
55
+ });
56
+
57
+ test('throws on invalid input', ({ expect }) => {
58
+ expect(() => EID.parse('dxn:org.dxos.type.calendar')).toThrow();
59
+ expect(() => EID.parse(`dxn:echo:@:${OBJECT}`)).toThrow();
60
+ expect(() => EID.parse(`dxn:queue:data:${SPACE}:${OBJECT}`)).toThrow();
61
+ expect(() => EID.parse('not-a-uri')).toThrow();
62
+ expect(() => EID.parse('echo:')).toThrow();
63
+ expect(() => EID.parse('echo://')).toThrow();
64
+ });
65
+ });
66
+
67
+ describe('EID.tryParse', () => {
68
+ test('returns undefined on failure instead of throwing', ({ expect }) => {
69
+ expect(EID.tryParse('not-a-uri')).toBeUndefined();
70
+ expect(EID.tryParse(`echo:/${OBJECT}`)).toBe(`echo:/${OBJECT}`);
71
+ });
72
+ });
73
+
74
+ describe('EID.getSpaceId', () => {
75
+ test('returns spaceId from qualified ref', ({ expect }) => {
76
+ expect(EID.getSpaceId(EID.make({ spaceId: SPACE, entityId: OBJECT }))).toBe(SPACE);
77
+ });
78
+
79
+ test('returns spaceId from space-only ref', ({ expect }) => {
80
+ expect(EID.getSpaceId(EID.make({ spaceId: SPACE }))).toBe(SPACE);
81
+ });
82
+
83
+ test('returns undefined for local ref', ({ expect }) => {
84
+ expect(EID.getSpaceId(EID.make({ entityId: OBJECT }))).toBeUndefined();
85
+ expect(EID.getSpaceId(EID.parse(`echo:///${OBJECT}`))).toBeUndefined();
86
+ });
87
+ });
88
+
89
+ describe('EID.getEntityId', () => {
90
+ test('returns objectId from qualified ref', ({ expect }) => {
91
+ expect(EID.getEntityId(EID.make({ spaceId: SPACE, entityId: OBJECT }))).toBe(OBJECT);
92
+ });
93
+
94
+ test('returns objectId from local ref', ({ expect }) => {
95
+ expect(EID.getEntityId(EID.make({ entityId: OBJECT }))).toBe(OBJECT);
96
+ expect(EID.getEntityId(EID.parse(`echo:///${OBJECT}`))).toBe(OBJECT);
97
+ });
98
+
99
+ test('returns undefined for space-only ref', ({ expect }) => {
100
+ expect(EID.getEntityId(EID.make({ spaceId: SPACE }))).toBeUndefined();
101
+ });
102
+ });
103
+
104
+ describe('EID.isLocal', () => {
105
+ test('returns true for local refs', ({ expect }) => {
106
+ expect(EID.isLocal(EID.make({ entityId: OBJECT }))).toBe(true);
107
+ expect(EID.isLocal(EID.parse(`echo:///${OBJECT}`))).toBe(true);
108
+ });
109
+
110
+ test('returns false for qualified refs', ({ expect }) => {
111
+ expect(EID.isLocal(EID.make({ spaceId: SPACE, entityId: OBJECT }))).toBe(false);
112
+ expect(EID.isLocal(EID.make({ spaceId: SPACE }))).toBe(false);
113
+ });
114
+ });
115
+
116
+ describe('EID.equals', () => {
117
+ test('returns true for identical refs', ({ expect }) => {
118
+ const id = EID.make({ spaceId: SPACE, entityId: OBJECT });
119
+ expect(EID.equals(id, id)).toBe(true);
120
+ });
121
+
122
+ test('returns false for different refs', ({ expect }) => {
123
+ const a = EID.make({ spaceId: SPACE, entityId: OBJECT });
124
+ const b = EID.make({ spaceId: SPACE, entityId: OBJECT2 });
125
+ expect(EID.equals(a, b)).toBe(false);
126
+ });
127
+ });
128
+
129
+ describe('EID.toLocal', () => {
130
+ test('drops the space from a qualified ref', ({ expect }) => {
131
+ expect(EID.toLocal(EID.make({ spaceId: SPACE, entityId: OBJECT }))).toBe(EID.make({ entityId: OBJECT }));
132
+ });
133
+
134
+ test('leaves a local ref unchanged', ({ expect }) => {
135
+ expect(EID.toLocal(EID.make({ entityId: OBJECT }))).toBe(EID.make({ entityId: OBJECT }));
136
+ });
137
+
138
+ test('collapses qualified and bare refs to the same value', ({ expect }) => {
139
+ expect(EID.toLocal(EID.make({ spaceId: SPACE, entityId: OBJECT }))).toBe(
140
+ EID.toLocal(EID.make({ entityId: OBJECT })),
141
+ );
142
+ });
143
+
144
+ test('returns space-only refs unchanged', ({ expect }) => {
145
+ expect(EID.toLocal(EID.make({ spaceId: SPACE }))).toBe(EID.make({ spaceId: SPACE }));
146
+ });
147
+ });
package/src/EID.ts ADDED
@@ -0,0 +1,151 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ // @import-as-namespace
6
+
7
+ import * as Schema from 'effect/Schema';
8
+
9
+ import type { EntityId } from './entity-id';
10
+ import type { SpaceId } from './space-id';
11
+ import type * as URI from './URI';
12
+
13
+ // Canonical-form regex covering all three EID shapes.
14
+ // echo://<spaceId>/<objectId>
15
+ // echo://<spaceId>
16
+ // echo:/<objectId> (local)
17
+ // echo:///<objectId> (local, alt form)
18
+ const ECHO_URI_REGEXP = /^echo:(?:\/\/[^/]+(?:\/[^/]+)?|(?:\/\/\/|\/)[^/]+)$/;
19
+
20
+ // Sub-patterns used for extraction.
21
+ const QUALIFIED_RE = /^echo:\/\/([^/]+)\/([^/]+)$/;
22
+ const SPACE_ONLY_RE = /^echo:\/\/([^/]+)$/;
23
+ const LOCAL_RE = /^echo:(?:\/\/\/|\/)([^/]+)$/;
24
+
25
+ /**
26
+ * Addresses an ECHO object or space. Uses the `echo:` URI scheme.
27
+ *
28
+ * @example
29
+ * ```
30
+ * echo://BA25QRC2FEWCSAMRP4RZL65LWJ7352CKE/01J00J9B45YHYSGZQTQMSKMGJ6
31
+ * echo:/01J00J9B45YHYSGZQTQMSKMGJ6
32
+ * echo://BA25QRC2FEWCSAMRP4RZL65LWJ7352CKE
33
+ * ```
34
+ */
35
+ export type EID = URI.URI & { readonly __EID: unique symbol };
36
+
37
+ /**
38
+ * Returns true if the value is a valid EID.
39
+ */
40
+ export const isEID = (value: unknown): value is EID => typeof value === 'string' && value.startsWith('echo:');
41
+
42
+ /**
43
+ * Parses a string to EID. Throws if the string is not a valid canonical `echo:` EID.
44
+ */
45
+ export const parse = (uri: string): EID => {
46
+ if (!ECHO_URI_REGEXP.test(uri)) {
47
+ throw new Error(`Invalid EID: ${uri}`);
48
+ }
49
+ return uri as EID;
50
+ };
51
+
52
+ /**
53
+ * Like `parse` but returns undefined on failure instead of throwing.
54
+ */
55
+ export const tryParse = (uri: string): EID | undefined => {
56
+ try {
57
+ return parse(uri);
58
+ } catch {
59
+ return undefined;
60
+ }
61
+ };
62
+
63
+ /**
64
+ * Constructs an EID. Validates the result via `parse`.
65
+ *
66
+ * - `{ spaceId, entityId }` → `echo://<spaceId>/<entityId>` (fully qualified)
67
+ * - `{ entityId }` → `echo:/<entityId>` (local — current space)
68
+ * - `{ spaceId }` → `echo://<spaceId>` (space-only)
69
+ *
70
+ * Throws if neither id is provided, or if the result is not a valid EID.
71
+ */
72
+ export const make = ({ spaceId, entityId }: { spaceId?: SpaceId; entityId?: EntityId }): EID => {
73
+ let raw: string;
74
+ if (spaceId != null && entityId != null) {
75
+ raw = `echo://${spaceId}/${entityId}`;
76
+ } else if (entityId != null) {
77
+ raw = `echo:/${entityId}`;
78
+ } else if (spaceId != null) {
79
+ raw = `echo://${spaceId}`;
80
+ } else {
81
+ throw new Error('EID.make requires at least one of spaceId or entityId');
82
+ }
83
+ return parse(raw);
84
+ };
85
+
86
+ /**
87
+ * Returns the SpaceId from a fully-qualified EID, or undefined for local refs.
88
+ */
89
+ export const getSpaceId = (uri: EID): SpaceId | undefined => {
90
+ const normalized = parse(uri);
91
+ const match = QUALIFIED_RE.exec(normalized) ?? SPACE_ONLY_RE.exec(normalized);
92
+ return match?.[1] as SpaceId | undefined;
93
+ };
94
+
95
+ /**
96
+ * Returns the EntityId from an EID, or undefined for space-only refs.
97
+ */
98
+ export const getEntityId = (uri: EID): EntityId | undefined => {
99
+ const normalized = parse(uri);
100
+ const qualMatch = QUALIFIED_RE.exec(normalized);
101
+ if (qualMatch) {
102
+ return qualMatch[2] as EntityId;
103
+ }
104
+ const localMatch = LOCAL_RE.exec(normalized);
105
+ return localMatch?.[1] as EntityId | undefined;
106
+ };
107
+
108
+ /**
109
+ * Returns true if the EID is a local reference (no authority/space).
110
+ */
111
+ export const isLocal = (uri: EID): boolean => {
112
+ const normalized = parse(uri);
113
+ return LOCAL_RE.test(normalized);
114
+ };
115
+
116
+ /**
117
+ * Returns the local (space-less) form of an EID, dropping any space authority. A space-qualified EID and a
118
+ * bare one for the same entity collapse to the same value; space-only EIDs (no entity id) are returned
119
+ * unchanged.
120
+ *
121
+ * Entity ids are unique within a space, so the local form is a safe key/comparison basis only among EIDs
122
+ * already known to belong to one space (e.g. a single space's reverse-ref index). Do NOT use it to decide
123
+ * whether two arbitrary EIDs name the same entity across spaces.
124
+ */
125
+ export const toLocal = (uri: EID): EID => {
126
+ const entityId = getEntityId(uri);
127
+ return entityId != null ? make({ entityId }) : parse(uri);
128
+ };
129
+
130
+ /**
131
+ * Returns true if the two EIDs refer to the same object, normalizing both first.
132
+ */
133
+ export const equals = (a: EID, b: EID): boolean => parse(a) === parse(b);
134
+
135
+ /**
136
+ * Effect Schema for EID validation.
137
+ */
138
+ // Identity-encoded schema (`Schema<EID, EID>`) so consumers can refine generic
139
+ // schemas without the encode/decode types diverging. `Schema.filter` produces a refinement
140
+ // with `Encoded = string`; we narrow the encoded form too with `as unknown as` since the
141
+ // runtime representation is identical (a branded string).
142
+ const Schema_: Schema.Schema<EID, EID> = Schema.String.pipe(
143
+ Schema.filter((value): value is EID => isEID(value), {
144
+ message: () => 'Invalid EID: must start with echo:',
145
+ }),
146
+ Schema.annotations({
147
+ title: 'EID',
148
+ description: 'ECHO object/space URI: echo://<spaceId>[/<objectId>] or echo:/<objectId>',
149
+ }),
150
+ ) as unknown as Schema.Schema<EID, EID>;
151
+ export { Schema_ as Schema };
package/src/URI.ts ADDED
@@ -0,0 +1,35 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ // @import-as-namespace
6
+
7
+ import * as Schema from 'effect/Schema';
8
+
9
+ /**
10
+ * Branded string type for any URI.
11
+ * Base type for more specific URI schemes like DXN and EID.
12
+ */
13
+ export type URI = string & { readonly __URI: unique symbol };
14
+
15
+ /**
16
+ * Brand a string as an opaque URI without validating the scheme.
17
+ * For typed construction prefer `DXN.make`, `EID.make`, etc.
18
+ */
19
+ export const make = (uri: string): URI => uri as URI;
20
+
21
+ /**
22
+ * Returns true if the value looks like a URI (string with a scheme prefix).
23
+ */
24
+ export const isURI = (value: unknown): value is URI =>
25
+ typeof value === 'string' && /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value);
26
+
27
+ /**
28
+ * Effect Schema for any URI string.
29
+ */
30
+ // Identity-encoded `Schema<URI, URI>` so consumers can refine without the encode/decode
31
+ // types diverging. Runtime representation is identical (a branded string).
32
+ const Schema_: Schema.Schema<URI, URI> = Schema.String.pipe(
33
+ Schema.filter((value): value is URI => isURI(value), { message: () => 'Invalid URI' }),
34
+ ) as unknown as Schema.Schema<URI, URI>;
35
+ export { Schema_ as Schema };
@@ -0,0 +1,73 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test, vi } from 'vitest';
6
+
7
+ import { EntityId } from './entity-id';
8
+
9
+ describe('EntityId', () => {
10
+ describe('isValid', () => {
11
+ test('accepts a randomly-generated id', ({ expect }) => {
12
+ const id = EntityId.random();
13
+ expect(EntityId.isValid(id)).toBe(true);
14
+ });
15
+
16
+ test('rejects malformed strings', ({ expect }) => {
17
+ expect(EntityId.isValid('')).toBe(false);
18
+ expect(EntityId.isValid('not-a-ulid')).toBe(false);
19
+ // Wrong leading char (must be 0-7).
20
+ expect(EntityId.isValid('8AAAAAAAAAAAAAAAAAAAAAAAAA')).toBe(false);
21
+ });
22
+ });
23
+
24
+ describe('deterministic', () => {
25
+ test('returns the same id for the same seeds', ({ expect }) => {
26
+ const first = EntityId.deterministic('org.dxos.type.person', '0.1.0');
27
+ const second = EntityId.deterministic('org.dxos.type.person', '0.1.0');
28
+ expect(first).toBe(second);
29
+ });
30
+
31
+ test('returns different ids for different seeds', ({ expect }) => {
32
+ const person = EntityId.deterministic('org.dxos.type.person', '0.1.0');
33
+ const organization = EntityId.deterministic('org.dxos.type.organization', '0.1.0');
34
+ expect(person).not.toBe(organization);
35
+ });
36
+
37
+ test('returns different ids when version differs', ({ expect }) => {
38
+ const v1 = EntityId.deterministic('org.dxos.type.person', '0.1.0');
39
+ const v2 = EntityId.deterministic('org.dxos.type.person', '0.2.0');
40
+ expect(v1).not.toBe(v2);
41
+ });
42
+
43
+ test('output passes EntityId.isValid', ({ expect }) => {
44
+ const id = EntityId.deterministic('org.dxos.type.person', '0.1.0');
45
+ expect(EntityId.isValid(id)).toBe(true);
46
+ });
47
+
48
+ test('accepts numeric seeds', ({ expect }) => {
49
+ const id = EntityId.deterministic('prefix', 42, 'suffix');
50
+ expect(EntityId.isValid(id)).toBe(true);
51
+ });
52
+
53
+ test('does not call crypto.getRandomValues', ({ expect }) => {
54
+ // Workerd forbids `crypto.getRandomValues()` in global scope; deterministic() must therefore
55
+ // never reach for the platform RNG. Verify by spying on the global.
56
+ const spy = vi.spyOn(globalThis.crypto, 'getRandomValues');
57
+ try {
58
+ EntityId.deterministic('org.dxos.type.person', '0.1.0');
59
+ expect(spy).not.toHaveBeenCalled();
60
+ } finally {
61
+ spy.mockRestore();
62
+ }
63
+ });
64
+
65
+ test('seed-component separator avoids collisions across adjacent inputs', ({ expect }) => {
66
+ // ('ab', 'c') vs ('a', 'bc') must hash differently — guards against naive
67
+ // concatenation that would conflate adjacent string seeds.
68
+ const left = EntityId.deterministic('ab', 'c');
69
+ const right = EntityId.deterministic('a', 'bc');
70
+ expect(left).not.toBe(right);
71
+ });
72
+ });
73
+ });