@workjournal/shared 2.19.0 → 2.27.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 (39) hide show
  1. package/dist/__tests__/address.test.d.ts +2 -0
  2. package/dist/__tests__/address.test.d.ts.map +1 -0
  3. package/dist/__tests__/address.test.js +211 -0
  4. package/dist/__tests__/address.test.js.map +1 -0
  5. package/dist/__tests__/export-formats.test.d.ts +2 -0
  6. package/dist/__tests__/export-formats.test.d.ts.map +1 -0
  7. package/dist/__tests__/export-formats.test.js +43 -0
  8. package/dist/__tests__/export-formats.test.js.map +1 -0
  9. package/dist/__tests__/notification-levels.test.d.ts +2 -0
  10. package/dist/__tests__/notification-levels.test.d.ts.map +1 -0
  11. package/dist/__tests__/notification-levels.test.js +54 -0
  12. package/dist/__tests__/notification-levels.test.js.map +1 -0
  13. package/dist/address.d.ts +81 -0
  14. package/dist/address.d.ts.map +1 -0
  15. package/dist/address.js +192 -0
  16. package/dist/address.js.map +1 -0
  17. package/dist/constants.d.ts +38 -0
  18. package/dist/constants.d.ts.map +1 -1
  19. package/dist/constants.js +38 -0
  20. package/dist/constants.js.map +1 -1
  21. package/dist/export-formats.d.ts +53 -0
  22. package/dist/export-formats.d.ts.map +1 -0
  23. package/dist/export-formats.js +26 -0
  24. package/dist/export-formats.js.map +1 -0
  25. package/dist/index.d.ts +9 -3
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +5 -2
  28. package/dist/index.js.map +1 -1
  29. package/dist/notification-levels.d.ts +53 -0
  30. package/dist/notification-levels.d.ts.map +1 -0
  31. package/dist/notification-levels.js +68 -0
  32. package/dist/notification-levels.js.map +1 -0
  33. package/dist/tiers.d.ts +7 -1
  34. package/dist/tiers.d.ts.map +1 -1
  35. package/dist/tiers.js +11 -0
  36. package/dist/tiers.js.map +1 -1
  37. package/dist/types.d.ts +127 -0
  38. package/dist/types.d.ts.map +1 -1
  39. package/package.json +1 -1
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=address.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"address.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/address.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,211 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ADDRESS_FAILURE_MESSAGES, formatAddress, formatEntryAddress, formatJournalAddress, formatWorkspaceAddress, hasAddressScheme, isAddress, MAX_ENTRY_INDEX, parseAddress, } from '../address.js';
3
+ import { MAX_SLUG_LENGTH } from '../slug.js';
4
+ function parsed(input) {
5
+ const result = parseAddress(input);
6
+ if (!result.ok)
7
+ throw new Error(`expected ${input} to parse, got ${result.reason}`);
8
+ return result.address;
9
+ }
10
+ function reason(input) {
11
+ const result = parseAddress(input);
12
+ if (result.ok)
13
+ throw new Error(`expected ${input} to fail, got ${formatAddress(result.address)}`);
14
+ return result.reason;
15
+ }
16
+ describe('parseAddress — valid forms', () => {
17
+ it('parses a workspace address', () => {
18
+ expect(parsed('wj:acme')).toEqual({ kind: 'workspace', workspaceSlug: 'acme' });
19
+ });
20
+ it('parses a journal address', () => {
21
+ expect(parsed('wj:acme.eng-notes')).toEqual({
22
+ kind: 'journal',
23
+ workspaceSlug: 'acme',
24
+ journalSlug: 'eng-notes',
25
+ });
26
+ });
27
+ it('parses an entry address', () => {
28
+ expect(parsed('wj:acme.eng-notes#42')).toEqual({
29
+ kind: 'entry',
30
+ workspaceSlug: 'acme',
31
+ journalSlug: 'eng-notes',
32
+ index: 42,
33
+ });
34
+ });
35
+ it('accepts numeric slugs', () => {
36
+ expect(parsed('wj:2026.q1#1')).toEqual({
37
+ kind: 'entry',
38
+ workspaceSlug: '2026',
39
+ journalSlug: 'q1',
40
+ index: 1,
41
+ });
42
+ });
43
+ it('accepts the maximum index', () => {
44
+ expect(parsed(`wj:acme.eng#${MAX_ENTRY_INDEX}`)).toMatchObject({ index: MAX_ENTRY_INDEX });
45
+ });
46
+ // The SQL backfill truncates AFTER trimming hyphens, so a live slug can end
47
+ // in `-`; the OpenAPI param pattern permits it too. Rejecting these would
48
+ // make an already-existing journal unaddressable.
49
+ it('accepts trailing and doubled hyphens, matching the OpenAPI slug pattern', () => {
50
+ expect(parsed('wj:acme-.eng--notes#1')).toMatchObject({
51
+ workspaceSlug: 'acme-',
52
+ journalSlug: 'eng--notes',
53
+ });
54
+ });
55
+ it('accepts slugs at the length cap', () => {
56
+ const slug = 'a'.repeat(MAX_SLUG_LENGTH);
57
+ expect(parsed(`wj:${slug}.${slug}#1`)).toMatchObject({
58
+ workspaceSlug: slug,
59
+ journalSlug: slug,
60
+ });
61
+ });
62
+ });
63
+ describe('parseAddress — normalisation', () => {
64
+ it('trims surrounding whitespace', () => {
65
+ expect(parsed(' wj:acme.eng-notes#7 ')).toMatchObject({ index: 7 });
66
+ });
67
+ it('lowercases scheme and slugs', () => {
68
+ expect(parsed('WJ:Acme.Eng-Notes#42')).toEqual({
69
+ kind: 'entry',
70
+ workspaceSlug: 'acme',
71
+ journalSlug: 'eng-notes',
72
+ index: 42,
73
+ });
74
+ });
75
+ it('round-trips through formatAddress in canonical form', () => {
76
+ expect(formatAddress(parsed(' WJ:Acme.Eng-Notes#42 '))).toBe('wj:acme.eng-notes#42');
77
+ });
78
+ });
79
+ describe('parseAddress — rejections', () => {
80
+ it.each([
81
+ ['acme.eng-notes#42', 'missing-scheme'],
82
+ ['https://workjournal.pro/acme', 'missing-scheme'],
83
+ ['acme/eng-notes/42', 'missing-scheme'],
84
+ ['', 'missing-scheme'],
85
+ ['wj:', 'empty-workspace'],
86
+ ['wj:.eng-notes', 'empty-workspace'],
87
+ ['wj:acme.', 'empty-journal'],
88
+ ['wj:acme.eng-notes#', 'empty-index'],
89
+ ['wj:acme#42', 'index-without-journal'],
90
+ ['wj:shared-with-me', 'virtual-workspace'],
91
+ ['wj:shared-with-me.eng-notes#1', 'virtual-workspace'],
92
+ ['wj:settings', 'reserved-slug'],
93
+ ['wj:acme.entries', 'reserved-slug'],
94
+ ['wj:acme.eng-notes#0', 'invalid-index'],
95
+ ['wj:acme.eng-notes#042', 'invalid-index'],
96
+ ['wj:acme.eng-notes#-1', 'invalid-index'],
97
+ ['wj:acme.eng-notes#4.2', 'invalid-index'],
98
+ ['wj:acme.eng-notes#abc', 'invalid-index'],
99
+ [`wj:acme.eng-notes#${MAX_ENTRY_INDEX + 1}`, 'invalid-index'],
100
+ ['wj:acme.eng.notes#1', 'invalid-slug'],
101
+ ['wj:acme.eng-notes#1#2', 'invalid-slug'],
102
+ ['wj:-acme', 'invalid-slug'],
103
+ ['wj:acme_notes', 'invalid-slug'],
104
+ ['wj:acme notes', 'invalid-slug'],
105
+ ['wj:café', 'invalid-slug'],
106
+ ])('rejects %s as %s', (input, expected) => {
107
+ expect(reason(input)).toBe(expected);
108
+ });
109
+ it('rejects slugs over the length cap', () => {
110
+ const tooLong = 'a'.repeat(MAX_SLUG_LENGTH + 1);
111
+ expect(reason(`wj:${tooLong}`)).toBe('invalid-slug');
112
+ expect(reason(`wj:acme.${tooLong}`)).toBe('invalid-slug');
113
+ });
114
+ it('has a message for every failure reason', () => {
115
+ const reasons = [
116
+ 'missing-scheme',
117
+ 'empty-workspace',
118
+ 'empty-journal',
119
+ 'empty-index',
120
+ 'invalid-slug',
121
+ 'reserved-slug',
122
+ 'virtual-workspace',
123
+ 'invalid-index',
124
+ 'index-without-journal',
125
+ ];
126
+ for (const r of reasons) {
127
+ expect(ADDRESS_FAILURE_MESSAGES[r]).toBeTruthy();
128
+ }
129
+ expect(Object.keys(ADDRESS_FAILURE_MESSAGES).sort()).toEqual([...reasons].sort());
130
+ });
131
+ });
132
+ describe('formatAddress', () => {
133
+ it('formats each kind', () => {
134
+ expect(formatAddress({ kind: 'workspace', workspaceSlug: 'acme' })).toBe('wj:acme');
135
+ expect(formatAddress({ kind: 'journal', workspaceSlug: 'acme', journalSlug: 'eng-notes' })).toBe('wj:acme.eng-notes');
136
+ expect(formatAddress({
137
+ kind: 'entry',
138
+ workspaceSlug: 'acme',
139
+ journalSlug: 'eng-notes',
140
+ index: 42,
141
+ })).toBe('wj:acme.eng-notes#42');
142
+ });
143
+ it('exposes loose-slug convenience formatters', () => {
144
+ expect(formatWorkspaceAddress('acme')).toBe('wj:acme');
145
+ expect(formatJournalAddress('acme', 'eng-notes')).toBe('wj:acme.eng-notes');
146
+ expect(formatEntryAddress('acme', 'eng-notes', 42)).toBe('wj:acme.eng-notes#42');
147
+ });
148
+ it('normalises slug case so output is always canonical', () => {
149
+ expect(formatAddress({ kind: 'journal', workspaceSlug: 'Acme', journalSlug: 'Eng' })).toBe('wj:acme.eng');
150
+ expect(formatEntryAddress(' ACME ', 'ENG', 7)).toBe('wj:acme.eng#7');
151
+ });
152
+ // formatAddress is exported and reachable from untyped JS; its contract says
153
+ // the output is canonical, so it must refuse to emit an unparseable string.
154
+ it.each([0, -1, 1.5, Number.NaN, MAX_ENTRY_INDEX + 1])('rejects index %s', (index) => {
155
+ expect(() => formatAddress({ kind: 'entry', workspaceSlug: 'acme', journalSlug: 'eng', index })).toThrow(RangeError);
156
+ });
157
+ it.each(['', 'has space', 'has.dot', 'has#hash', '-leading'])('rejects slug %j', (slug) => {
158
+ expect(() => formatAddress({ kind: 'workspace', workspaceSlug: slug })).toThrow(TypeError);
159
+ });
160
+ // `parseAddress` rejects reserved slugs in both positions, so formatAddress
161
+ // emitting one would break the round-trip it documents (#782 follow-up).
162
+ it.each([
163
+ 'new',
164
+ 'settings',
165
+ 'journals',
166
+ 'entries',
167
+ 'shared-with-me',
168
+ ])('rejects reserved workspace slug %j', (slug) => {
169
+ expect(() => formatAddress({ kind: 'workspace', workspaceSlug: slug })).toThrow(TypeError);
170
+ });
171
+ it.each([
172
+ 'new',
173
+ 'settings',
174
+ 'invites',
175
+ 'shared-with-me',
176
+ ])('rejects reserved journal slug %j', (slug) => {
177
+ expect(() => formatAddress({ kind: 'journal', workspaceSlug: 'acme', journalSlug: slug })).toThrow(TypeError);
178
+ expect(() => formatEntryAddress('acme', slug, 1)).toThrow(TypeError);
179
+ });
180
+ it('never emits a string parseAddress would reject', () => {
181
+ // The contract, stated as a property: anything formatAddress returns must
182
+ // parse back. Reserved slugs were the hole — they formatted fine and then
183
+ // failed to parse.
184
+ for (const slug of ['new', 'shared-with-me', 'settings']) {
185
+ expect(() => formatWorkspaceAddress(slug)).toThrow(TypeError);
186
+ expect(parseAddress(`wj:${slug}`).ok).toBe(false);
187
+ }
188
+ });
189
+ it('output of formatAddress always parses back', () => {
190
+ const formatted = formatEntryAddress('Acme', 'Eng-Notes', 42);
191
+ expect(parseAddress(formatted).ok).toBe(true);
192
+ });
193
+ it('round-trips every valid kind', () => {
194
+ for (const input of ['wj:acme', 'wj:acme.eng-notes', 'wj:acme.eng-notes#42']) {
195
+ expect(formatAddress(parsed(input))).toBe(input);
196
+ }
197
+ });
198
+ });
199
+ describe('isAddress / hasAddressScheme', () => {
200
+ it('isAddress is true only for parseable input', () => {
201
+ expect(isAddress('wj:acme.eng-notes#42')).toBe(true);
202
+ expect(isAddress('wj:acme.eng-notes#0')).toBe(false);
203
+ expect(isAddress('acme')).toBe(false);
204
+ });
205
+ it('hasAddressScheme is true for malformed addresses too', () => {
206
+ expect(hasAddressScheme('wj:acme.eng-notes#0')).toBe(true);
207
+ expect(hasAddressScheme(' WJ:whatever ')).toBe(true);
208
+ expect(hasAddressScheme('acme')).toBe(false);
209
+ });
210
+ });
211
+ //# sourceMappingURL=address.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"address.test.js","sourceRoot":"","sources":["../../src/__tests__/address.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=export-formats.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"export-formats.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/export-formats.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { WS_API_PATHS } from '../constants.js';
3
+ import { EXPORT_FORMAT_IDS, EXPORT_FORMATS, getExportFormat, isExportFormatId, } from '../export-formats.js';
4
+ import { tierNamesWithFeature } from '../tiers.js';
5
+ describe('EXPORT_FORMATS', () => {
6
+ it('lists every format exactly once', () => {
7
+ expect(EXPORT_FORMAT_IDS).toEqual(['json', 'md', 'csv', 'epub']);
8
+ expect(new Set(EXPORT_FORMAT_IDS).size).toBe(EXPORT_FORMATS.length);
9
+ });
10
+ it('gates EPUB behind can_export_epub and leaves the text formats open', () => {
11
+ expect(getExportFormat('epub').feature).toBe('can_export_epub');
12
+ for (const id of ['json', 'md', 'csv']) {
13
+ expect(getExportFormat(id).feature).toBeUndefined();
14
+ }
15
+ });
16
+ it('recognises only registered ids', () => {
17
+ expect(isExportFormatId('epub')).toBe(true);
18
+ expect(isExportFormatId('pdf')).toBe(false);
19
+ expect(isExportFormatId(undefined)).toBe(false);
20
+ expect(isExportFormatId('toString')).toBe(false);
21
+ });
22
+ });
23
+ describe('tierNamesWithFeature', () => {
24
+ const tier = (code, name, sort_order, epub) => ({ code, name, sort_order, can_export_epub: epub });
25
+ it('names the purchasable tiers with the flag, in upgrade order, without ADMIN', () => {
26
+ const tiers = [
27
+ tier('PRO', 'Pro', 3, true),
28
+ tier('ADMIN', 'Admin', 4, true),
29
+ tier('FREE', 'Free', 1, false),
30
+ tier('PLUS', 'Plus', 2, true),
31
+ ];
32
+ expect(tierNamesWithFeature(tiers, 'can_export_epub')).toEqual(['Plus', 'Pro']);
33
+ });
34
+ });
35
+ describe('WS_API_PATHS export link helpers', () => {
36
+ it('builds the journal-scoped mint path', () => {
37
+ expect(WS_API_PATHS.exportLink('acme', 'eng')).toBe('/v1/workspaces/acme/journals/eng/export-link');
38
+ });
39
+ it('builds the token download path', () => {
40
+ expect(WS_API_PATHS.exportDownload('abc_-1')).toBe('/v1/exports/abc_-1');
41
+ });
42
+ });
43
+ //# sourceMappingURL=export-formats.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"export-formats.test.js","sourceRoot":"","sources":["../../src/__tests__/export-formats.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=notification-levels.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notification-levels.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/notification-levels.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,54 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { isConfigurableNotificationType, isNotificationLevel, NOTIFICATION_TYPE_GROUPS, NOTIFICATION_TYPE_OPTIONS, resolveNotificationLevel, } from '../notification-levels.js';
5
+ describe('NOTIFICATION_TYPE_OPTIONS', () => {
6
+ it('has unique types, each in a known group', () => {
7
+ const types = NOTIFICATION_TYPE_OPTIONS.map((o) => o.type);
8
+ expect(new Set(types).size).toBe(types.length);
9
+ const groups = new Set(NOTIFICATION_TYPE_GROUPS.map((g) => g.group));
10
+ for (const o of NOTIFICATION_TYPE_OPTIONS)
11
+ expect(groups.has(o.group)).toBe(true);
12
+ });
13
+ it('scopes member.* to the workspace and everything else to journals', () => {
14
+ for (const o of NOTIFICATION_TYPE_OPTIONS) {
15
+ expect(o.scope).toBe(o.type.startsWith('member.') ? 'workspace' : 'journal');
16
+ }
17
+ });
18
+ });
19
+ describe('resolveNotificationLevel', () => {
20
+ it('defaults to list for missing keys and malformed maps', () => {
21
+ expect(resolveNotificationLevel(undefined, 'entry.created')).toBe('list');
22
+ expect(resolveNotificationLevel(null, 'entry.created')).toBe('list');
23
+ expect(resolveNotificationLevel([], 'entry.created')).toBe('list');
24
+ expect(resolveNotificationLevel({}, 'entry.created')).toBe('list');
25
+ expect(resolveNotificationLevel({ 'entry.created': 'loud' }, 'entry.created')).toBe('list');
26
+ });
27
+ it('returns the stored level for configurable types', () => {
28
+ expect(resolveNotificationLevel({ 'entry.updated': 'hide' }, 'entry.updated')).toBe('hide');
29
+ expect(resolveNotificationLevel({ 'member.joined': 'push' }, 'member.joined')).toBe('push');
30
+ });
31
+ it('ignores stored levels for types outside the registry', () => {
32
+ expect(isConfigurableNotificationType('tag.created')).toBe(false);
33
+ expect(resolveNotificationLevel({ 'tag.created': 'hide' }, 'tag.created')).toBe('list');
34
+ });
35
+ });
36
+ describe('isNotificationLevel', () => {
37
+ it('accepts exactly hide / list / push', () => {
38
+ expect(['hide', 'list', 'push'].every(isNotificationLevel)).toBe(true);
39
+ expect(isNotificationLevel('show')).toBe(false);
40
+ expect(isNotificationLevel(null)).toBe(false);
41
+ });
42
+ });
43
+ describe('patch_my_notification_levels SQL allow-list', () => {
44
+ it('matches NOTIFICATION_TYPE_OPTIONS exactly', () => {
45
+ // The RPC is callable directly through PostgREST, so it re-validates keys
46
+ // against its own copy of the registry (#805). Fail if they drift.
47
+ const sql = readFileSync(fileURLToPath(new URL('../../../../supabase/migrations/20261005000001_workspace_user_settings.sql', import.meta.url)), 'utf8');
48
+ const block = sql.match(/notification-types:begin([\s\S]*?)notification-types:end/)?.[1] ?? '';
49
+ const sqlTypes = [...block.matchAll(/'([a-z_]+\.[a-z_]+)'/g)].map((m) => m[1]);
50
+ expect(new Set(sqlTypes)).toEqual(new Set(NOTIFICATION_TYPE_OPTIONS.map((o) => o.type)));
51
+ expect(sqlTypes).toHaveLength(NOTIFICATION_TYPE_OPTIONS.length);
52
+ });
53
+ });
54
+ //# sourceMappingURL=notification-levels.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notification-levels.test.js","sourceRoot":"","sources":["../../src/__tests__/notification-levels.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The `wj:` address standard — one notation for naming a workspace, a journal,
3
+ * or an entry across every surface. See `docs/addressing.md` for the spec.
4
+ *
5
+ * wj:acme → workspace
6
+ * wj:acme.eng-notes → journal
7
+ * wj:acme.eng-notes#42 → entry
8
+ *
9
+ * This module is the single implementation. No surface may hand-roll the
10
+ * grammar — `connector-app.ts` used to carry its own `ID_PATTERN` and drifting
11
+ * copies are exactly what this replaces.
12
+ */
13
+ export declare const ADDRESS_SCHEME: "wj:";
14
+ /**
15
+ * `entries.index` is a Postgres INTEGER. Rejecting above the ceiling at parse
16
+ * time keeps an over-large address from becoming a confusing 400 from the API.
17
+ */
18
+ export declare const MAX_ENTRY_INDEX = 2147483647;
19
+ export type WjAddress = {
20
+ kind: 'workspace';
21
+ workspaceSlug: string;
22
+ } | {
23
+ kind: 'journal';
24
+ workspaceSlug: string;
25
+ journalSlug: string;
26
+ } | {
27
+ kind: 'entry';
28
+ workspaceSlug: string;
29
+ journalSlug: string;
30
+ index: number;
31
+ };
32
+ export type AddressParseFailure = 'missing-scheme' | 'empty-workspace' | 'empty-journal' | 'empty-index' | 'invalid-slug' | 'reserved-slug' | 'virtual-workspace' | 'invalid-index' | 'index-without-journal';
33
+ export type ParseAddressResult = {
34
+ ok: true;
35
+ address: WjAddress;
36
+ } | {
37
+ ok: false;
38
+ reason: AddressParseFailure;
39
+ };
40
+ /** Human-readable explanation per failure reason, for CLI/MCP error messages. */
41
+ export declare const ADDRESS_FAILURE_MESSAGES: Readonly<Record<AddressParseFailure, string>>;
42
+ /**
43
+ * Parse a `wj:` address.
44
+ *
45
+ * Liberal in what it accepts — surrounding whitespace is trimmed and the input
46
+ * is lowercased, so `WJ:Acme.Eng-Notes#42` parses — but the parsed result is
47
+ * always canonical, so `formatAddress(parse(x))` is a normalising round-trip.
48
+ *
49
+ * Returns a `reason` rather than throwing (or returning bare `null`) so callers
50
+ * can render something the user can act on: "uses a reserved slug" is
51
+ * actionable where "malformed" is not.
52
+ */
53
+ export declare function parseAddress(input: string): ParseAddressResult;
54
+ /**
55
+ * Render an address in canonical form. Inverse of `parseAddress`.
56
+ *
57
+ * Normalises slug case and rejects anything that would produce an unparseable
58
+ * string. The type system stops most misuse, but `formatAddress` is exported
59
+ * and reachable from untyped JS, and its documented contract is that the
60
+ * output is *canonical* — silently emitting `wj:Acme.eng#0` would break that
61
+ * for every consumer that later parses it back.
62
+ */
63
+ export declare function formatAddress(address: WjAddress): string;
64
+ /** Convenience formatters for callers holding loose slugs rather than a union. */
65
+ export declare function formatWorkspaceAddress(workspaceSlug: string): string;
66
+ export declare function formatJournalAddress(workspaceSlug: string, journalSlug: string): string;
67
+ export declare function formatEntryAddress(workspaceSlug: string, journalSlug: string, index: number): string;
68
+ /**
69
+ * Cheap check for "does this look like an address" — used to decide whether to
70
+ * treat a CLI positional as an address or as a bare slug. Note this returns
71
+ * false for a malformed address; use `parseAddress` when you need to tell a
72
+ * non-address apart from a broken one.
73
+ */
74
+ export declare function isAddress(input: string): boolean;
75
+ /**
76
+ * True when the input is *trying* to be an address, valid or not. The CLI uses
77
+ * this to route a `wj:`-prefixed positional into the address parser so a typo
78
+ * reports the real reason instead of being treated as a workspace slug.
79
+ */
80
+ export declare function hasAddressScheme(input: string): boolean;
81
+ //# sourceMappingURL=address.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"address.d.ts","sourceRoot":"","sources":["../src/address.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;GAWG;AAEH,eAAO,MAAM,cAAc,EAAG,KAAc,CAAC;AAE7C;;;GAGG;AACH,eAAO,MAAM,eAAe,aAAa,CAAC;AAsB1C,MAAM,MAAM,SAAS,GAClB;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,aAAa,EAAE,MAAM,CAAA;CAAE,GAC5C;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,aAAa,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,GAC/D;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,aAAa,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAEhF,MAAM,MAAM,mBAAmB,GAC5B,gBAAgB,GAChB,iBAAiB,GACjB,eAAe,GACf,aAAa,GACb,cAAc,GACd,eAAe,GACf,mBAAmB,GACnB,eAAe,GACf,uBAAuB,CAAC;AAE3B,MAAM,MAAM,kBAAkB,GAC3B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,OAAO,EAAE,SAAS,CAAA;CAAE,GAChC;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,mBAAmB,CAAA;CAAE,CAAC;AAE9C,iFAAiF;AACjF,eAAO,MAAM,wBAAwB,EAAE,QAAQ,CAAC,MAAM,CAAC,mBAAmB,EAAE,MAAM,CAAC,CAWlF,CAAC;AAUF;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,kBAAkB,CAwD9D;AAED;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,SAAS,GAAG,MAAM,CAkBxD;AAiBD,kFAAkF;AAClF,wBAAgB,sBAAsB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAEpE;AAED,wBAAgB,oBAAoB,CAAC,aAAa,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,CAEvF;AAED,wBAAgB,kBAAkB,CACjC,aAAa,EAAE,MAAM,EACrB,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,MAAM,GACX,MAAM,CAER;AAED;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAEhD;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAEvD"}
@@ -0,0 +1,192 @@
1
+ import { SHARED_WITH_ME_SLUG } from './constants.js';
2
+ import { isReservedSlug, MAX_SLUG_LENGTH } from './slug.js';
3
+ /**
4
+ * The `wj:` address standard — one notation for naming a workspace, a journal,
5
+ * or an entry across every surface. See `docs/addressing.md` for the spec.
6
+ *
7
+ * wj:acme → workspace
8
+ * wj:acme.eng-notes → journal
9
+ * wj:acme.eng-notes#42 → entry
10
+ *
11
+ * This module is the single implementation. No surface may hand-roll the
12
+ * grammar — `connector-app.ts` used to carry its own `ID_PATTERN` and drifting
13
+ * copies are exactly what this replaces.
14
+ */
15
+ export const ADDRESS_SCHEME = 'wj:';
16
+ /**
17
+ * `entries.index` is a Postgres INTEGER. Rejecting above the ceiling at parse
18
+ * time keeps an over-large address from becoming a confusing 400 from the API.
19
+ */
20
+ export const MAX_ENTRY_INDEX = 2147483647;
21
+ /**
22
+ * Slug shape shared by both address positions. Deliberately identical to the
23
+ * OpenAPI `pattern` already documented on the workspace/journal slug params
24
+ * (`^[a-z0-9][a-z0-9-]{0,39}$`) — the length half is applied separately below.
25
+ *
26
+ * Note this is looser than `slugify()`'s own output: it tolerates a trailing
27
+ * hyphen and doubled hyphens. That is deliberate. The SQL backfill in
28
+ * `20260422000002_add_slug_columns` truncates with `substring(... FOR 30)`
29
+ * *after* trimming hyphens, so a live slug can legitimately end in `-`.
30
+ * Rejecting those here would make an existing journal unaddressable.
31
+ *
32
+ * What the parser actually depends on is narrower: a slug can never contain
33
+ * `.`, `#`, or `:`. That is what makes the grammar unambiguous, and it is the
34
+ * invariant the DB CHECK constraint enforces.
35
+ */
36
+ const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
37
+ /** Positive integer, no leading zeros. Mirrors the old connector-app pattern. */
38
+ const INDEX_PATTERN = /^[1-9]\d*$/;
39
+ /** Human-readable explanation per failure reason, for CLI/MCP error messages. */
40
+ export const ADDRESS_FAILURE_MESSAGES = {
41
+ 'missing-scheme': `Address must start with "${ADDRESS_SCHEME}"`,
42
+ 'empty-workspace': 'Address is missing a workspace slug',
43
+ 'empty-journal': 'Address has a "." but no journal slug',
44
+ 'empty-index': 'Address has a "#" but no entry number',
45
+ 'invalid-slug': 'Slugs must start with a lowercase letter or digit and contain only lowercase letters, digits, and hyphens',
46
+ 'reserved-slug': 'Address uses a reserved slug',
47
+ 'virtual-workspace': `"${SHARED_WITH_ME_SLUG}" is a virtual listing, not an addressable workspace — use the journal's real workspace slug`,
48
+ 'invalid-index': `Entry number must be a positive integer with no leading zeros, at most ${MAX_ENTRY_INDEX}`,
49
+ 'index-without-journal': 'Address has an entry number but no journal slug',
50
+ };
51
+ function isValidSlug(value) {
52
+ return value.length <= MAX_SLUG_LENGTH && SLUG_PATTERN.test(value);
53
+ }
54
+ function fail(reason) {
55
+ return { ok: false, reason };
56
+ }
57
+ /**
58
+ * Parse a `wj:` address.
59
+ *
60
+ * Liberal in what it accepts — surrounding whitespace is trimmed and the input
61
+ * is lowercased, so `WJ:Acme.Eng-Notes#42` parses — but the parsed result is
62
+ * always canonical, so `formatAddress(parse(x))` is a normalising round-trip.
63
+ *
64
+ * Returns a `reason` rather than throwing (or returning bare `null`) so callers
65
+ * can render something the user can act on: "uses a reserved slug" is
66
+ * actionable where "malformed" is not.
67
+ */
68
+ export function parseAddress(input) {
69
+ const normalized = input.trim().toLowerCase();
70
+ if (!normalized.startsWith(ADDRESS_SCHEME))
71
+ return fail('missing-scheme');
72
+ const rest = normalized.slice(ADDRESS_SCHEME.length);
73
+ // Split on the LAST `#` and the FIRST `.`. Safe because neither character
74
+ // can occur inside a slug — a second `.` or `#` lands inside a segment and
75
+ // is rejected by the slug check rather than silently re-splitting.
76
+ const hashAt = rest.lastIndexOf('#');
77
+ const head = hashAt === -1 ? rest : rest.slice(0, hashAt);
78
+ const indexPart = hashAt === -1 ? null : rest.slice(hashAt + 1);
79
+ const dotAt = head.indexOf('.');
80
+ const workspacePart = dotAt === -1 ? head : head.slice(0, dotAt);
81
+ const journalPart = dotAt === -1 ? null : head.slice(dotAt + 1);
82
+ if (indexPart !== null && journalPart === null)
83
+ return fail('index-without-journal');
84
+ if (workspacePart.length === 0)
85
+ return fail('empty-workspace');
86
+ if (!isValidSlug(workspacePart))
87
+ return fail('invalid-slug');
88
+ // Checked before the general reserved test so the caller gets the specific
89
+ // reason — `shared-with-me` is in RESERVED_SLUGS too, but it fails for a
90
+ // different reason than a route-collision name would.
91
+ if (workspacePart === SHARED_WITH_ME_SLUG)
92
+ return fail('virtual-workspace');
93
+ if (isReservedSlug(workspacePart))
94
+ return fail('reserved-slug');
95
+ if (journalPart === null) {
96
+ return { ok: true, address: { kind: 'workspace', workspaceSlug: workspacePart } };
97
+ }
98
+ if (journalPart.length === 0)
99
+ return fail('empty-journal');
100
+ if (!isValidSlug(journalPart))
101
+ return fail('invalid-slug');
102
+ if (isReservedSlug(journalPart))
103
+ return fail('reserved-slug');
104
+ if (indexPart === null) {
105
+ return {
106
+ ok: true,
107
+ address: { kind: 'journal', workspaceSlug: workspacePart, journalSlug: journalPart },
108
+ };
109
+ }
110
+ if (indexPart.length === 0)
111
+ return fail('empty-index');
112
+ if (!INDEX_PATTERN.test(indexPart))
113
+ return fail('invalid-index');
114
+ const index = Number(indexPart);
115
+ if (index > MAX_ENTRY_INDEX)
116
+ return fail('invalid-index');
117
+ return {
118
+ ok: true,
119
+ address: {
120
+ kind: 'entry',
121
+ workspaceSlug: workspacePart,
122
+ journalSlug: journalPart,
123
+ index,
124
+ },
125
+ };
126
+ }
127
+ /**
128
+ * Render an address in canonical form. Inverse of `parseAddress`.
129
+ *
130
+ * Normalises slug case and rejects anything that would produce an unparseable
131
+ * string. The type system stops most misuse, but `formatAddress` is exported
132
+ * and reachable from untyped JS, and its documented contract is that the
133
+ * output is *canonical* — silently emitting `wj:Acme.eng#0` would break that
134
+ * for every consumer that later parses it back.
135
+ */
136
+ export function formatAddress(address) {
137
+ const workspaceSlug = normalizeSlugOrThrow(address.workspaceSlug, 'workspaceSlug');
138
+ if (address.kind === 'workspace') {
139
+ return `${ADDRESS_SCHEME}${workspaceSlug}`;
140
+ }
141
+ const journalSlug = normalizeSlugOrThrow(address.journalSlug, 'journalSlug');
142
+ if (address.kind === 'journal') {
143
+ return `${ADDRESS_SCHEME}${workspaceSlug}.${journalSlug}`;
144
+ }
145
+ const { index } = address;
146
+ if (!Number.isInteger(index) || index < 1 || index > MAX_ENTRY_INDEX) {
147
+ throw new RangeError(`formatAddress: index must be an integer in 1..${MAX_ENTRY_INDEX}, got ${String(index)}`);
148
+ }
149
+ return `${ADDRESS_SCHEME}${workspaceSlug}.${journalSlug}#${index}`;
150
+ }
151
+ function normalizeSlugOrThrow(slug, field) {
152
+ const normalized = typeof slug === 'string' ? slug.trim().toLowerCase() : '';
153
+ if (!isValidSlug(normalized)) {
154
+ throw new TypeError(`formatAddress: ${field} is not a valid slug: ${JSON.stringify(slug)}`);
155
+ }
156
+ // `parseAddress` rejects reserved slugs in both positions, so emitting one
157
+ // would produce a string that cannot be parsed back — breaking the canonical
158
+ // round-trip this function documents. `shared-with-me` is in RESERVED_SLUGS,
159
+ // so this covers the virtual-workspace case too.
160
+ if (isReservedSlug(normalized)) {
161
+ throw new TypeError(`formatAddress: ${field} is a reserved slug: ${JSON.stringify(slug)}`);
162
+ }
163
+ return normalized;
164
+ }
165
+ /** Convenience formatters for callers holding loose slugs rather than a union. */
166
+ export function formatWorkspaceAddress(workspaceSlug) {
167
+ return formatAddress({ kind: 'workspace', workspaceSlug });
168
+ }
169
+ export function formatJournalAddress(workspaceSlug, journalSlug) {
170
+ return formatAddress({ kind: 'journal', workspaceSlug, journalSlug });
171
+ }
172
+ export function formatEntryAddress(workspaceSlug, journalSlug, index) {
173
+ return formatAddress({ kind: 'entry', workspaceSlug, journalSlug, index });
174
+ }
175
+ /**
176
+ * Cheap check for "does this look like an address" — used to decide whether to
177
+ * treat a CLI positional as an address or as a bare slug. Note this returns
178
+ * false for a malformed address; use `parseAddress` when you need to tell a
179
+ * non-address apart from a broken one.
180
+ */
181
+ export function isAddress(input) {
182
+ return parseAddress(input).ok;
183
+ }
184
+ /**
185
+ * True when the input is *trying* to be an address, valid or not. The CLI uses
186
+ * this to route a `wj:`-prefixed positional into the address parser so a typo
187
+ * reports the real reason instead of being treated as a workspace slug.
188
+ */
189
+ export function hasAddressScheme(input) {
190
+ return input.trim().toLowerCase().startsWith(ADDRESS_SCHEME);
191
+ }
192
+ //# sourceMappingURL=address.js.map