@manablox/db 0.2.0 → 0.4.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 (61) hide show
  1. package/dist/index-BLAkQMJT.d.ts +877 -0
  2. package/dist/index-DrNMGM9N.d.ts +5193 -0
  3. package/dist/index.d.ts +123 -0
  4. package/dist/index.js +60 -0
  5. package/dist/repositories-pz4NeWaF.js +1928 -0
  6. package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
  7. package/dist/schema-Dm3RcBst.js +648 -0
  8. package/dist/schema.d.ts +2 -0
  9. package/dist/schema.js +2 -0
  10. package/dist/testing.d.ts +77 -0
  11. package/dist/testing.js +217 -0
  12. package/migrations/0009_audit-log.sql +40 -0
  13. package/migrations/0010_notifications-approvals.sql +45 -0
  14. package/migrations/meta/0009_snapshot.json +3192 -0
  15. package/migrations/meta/0010_snapshot.json +3574 -0
  16. package/migrations/meta/_journal.json +14 -0
  17. package/package.json +18 -10
  18. package/drizzle.config.ts +0 -11
  19. package/src/bootstrap.ts +0 -13
  20. package/src/cli/create-db.ts +0 -30
  21. package/src/cli/migrate.ts +0 -17
  22. package/src/client.ts +0 -44
  23. package/src/columns.ts +0 -39
  24. package/src/errors.ts +0 -50
  25. package/src/index.ts +0 -19
  26. package/src/migrate.ts +0 -21
  27. package/src/pagination.ts +0 -52
  28. package/src/query.ts +0 -213
  29. package/src/repositories/asset-usage.ts +0 -166
  30. package/src/repositories/asset.ts +0 -181
  31. package/src/repositories/content-type.ts +0 -116
  32. package/src/repositories/content.ts +0 -811
  33. package/src/repositories/index.ts +0 -40
  34. package/src/repositories/menu.ts +0 -235
  35. package/src/repositories/role.ts +0 -85
  36. package/src/repositories/space.ts +0 -83
  37. package/src/repositories/user.ts +0 -280
  38. package/src/repositories/webhook.ts +0 -46
  39. package/src/repositories/workflow.ts +0 -306
  40. package/src/schema/assets.ts +0 -108
  41. package/src/schema/auth.ts +0 -166
  42. package/src/schema/content-types.ts +0 -31
  43. package/src/schema/content.ts +0 -133
  44. package/src/schema/index.ts +0 -38
  45. package/src/schema/menus.ts +0 -61
  46. package/src/schema/relations.ts +0 -64
  47. package/src/schema/spaces.ts +0 -20
  48. package/src/schema/webhooks.ts +0 -46
  49. package/src/schema/workflows.ts +0 -92
  50. package/src/testing-fixtures.ts +0 -139
  51. package/src/testing.ts +0 -105
  52. package/test/asset-usage.test.ts +0 -101
  53. package/test/menu.test.ts +0 -126
  54. package/test/publish.test.ts +0 -130
  55. package/test/query.test.ts +0 -170
  56. package/test/role.test.ts +0 -81
  57. package/test/tree.test.ts +0 -188
  58. package/test/user.test.ts +0 -126
  59. package/test/webhook.test.ts +0 -48
  60. package/tsconfig.json +0 -4
  61. package/vitest.config.ts +0 -10
@@ -1,130 +0,0 @@
1
- import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2
- import { createRepositoryContext, makeNode, type RepositoryTestContext } from '../src/testing.js';
3
-
4
- let ctx: RepositoryTestContext;
5
-
6
- beforeAll(async () => {
7
- ctx = await createRepositoryContext('publish');
8
- });
9
- afterAll(async () => {
10
- await ctx?.close();
11
- });
12
-
13
- const update = (id: string, over: Record<string, unknown>) =>
14
- ctx.repos.content.update(id, {
15
- spaceId: ctx.spaceId,
16
- typeId: ctx.types.page.id,
17
- locale: 'en',
18
- title: 'T',
19
- slug: 's',
20
- fields: {},
21
- hasSlug: true,
22
- ...over,
23
- } as never);
24
-
25
- describe('publishing', () => {
26
- it('projects a draft into the delivery table', async () => {
27
- const node = await makeNode(ctx, { title: 'Post', slug: 'post', fields: { body: 'v1' } });
28
- expect(await ctx.repos.content.findById(node.id, true)).toBeNull();
29
-
30
- await ctx.repos.content.publish(node.id);
31
-
32
- const published = await ctx.repos.content.findById(node.id, true);
33
- expect(published?.permalink).toBe('post');
34
- expect(published?.status).toBe('published');
35
- expect((published?.fields as Record<string, unknown>).body).toBe('v1');
36
- });
37
-
38
- it('keeps the draft and the published projection independent', async () => {
39
- const node = await makeNode(ctx, {
40
- title: 'Draftable',
41
- slug: 'draftable',
42
- fields: { body: 'v1' },
43
- });
44
- await ctx.repos.content.publish(node.id);
45
-
46
- await update(node.id, { title: 'Draftable', slug: 'draftable', fields: { body: 'v2' } });
47
-
48
- const draft = await ctx.repos.content.findById(node.id);
49
- const published = await ctx.repos.content.findById(node.id, true);
50
- expect((draft?.fields as Record<string, unknown>).body).toBe('v2');
51
- expect((published?.fields as Record<string, unknown>).body).toBe('v1');
52
- });
53
-
54
- it('rewrites published descendants when the parent permalink changes', async () => {
55
- const root = await makeNode(ctx, { title: 'R', slug: 'r' });
56
- const child = await makeNode(ctx, { title: 'C', slug: 'c', parentId: root.id });
57
- await ctx.repos.content.publish(root.id);
58
- await ctx.repos.content.publish(child.id);
59
-
60
- await update(root.id, { title: 'R', slug: 'renamed', parentId: null });
61
- await ctx.repos.content.publish(root.id);
62
-
63
- const publishedChild = await ctx.repos.content.findById(child.id, true);
64
- expect(publishedChild?.permalink).toBe('renamed/c');
65
- });
66
-
67
- it('removes the subtree from the projection on unpublish', async () => {
68
- const root = await makeNode(ctx, { title: 'U', slug: 'u' });
69
- const child = await makeNode(ctx, { title: 'U1', slug: 'u1', parentId: root.id });
70
- await ctx.repos.content.publish(root.id);
71
- await ctx.repos.content.publish(child.id);
72
-
73
- await ctx.repos.content.unpublish(root.id);
74
-
75
- expect(await ctx.repos.content.findById(root.id, true)).toBeNull();
76
- expect(await ctx.repos.content.findById(child.id, true)).toBeNull();
77
- });
78
- });
79
-
80
- describe('versions and optimistic locking', () => {
81
- it('snapshots every save', async () => {
82
- const node = await makeNode(ctx, { title: 'V', slug: 'v', fields: { body: 'one' } });
83
- await update(node.id, { title: 'V', slug: 'v', fields: { body: 'two' } });
84
- await update(node.id, { title: 'V', slug: 'v', fields: { body: 'three' } });
85
-
86
- const versions = await ctx.repos.content.versions(node.id);
87
- expect(versions.map((v) => v.version)).toEqual([3, 2, 1]);
88
-
89
- const first = await ctx.repos.content.versionSnapshot(node.id, 1);
90
- expect((first?.fields as Record<string, unknown>).body).toBe('one');
91
- });
92
-
93
- /** Optimistic locking: a stale version must not silently win. */
94
- it('rejects a write built on a stale version', async () => {
95
- const node = await makeNode(ctx, { title: 'L', slug: 'lock' });
96
- await update(node.id, { title: 'L', slug: 'lock', expectedVersion: 1 });
97
-
98
- await expect(update(node.id, { title: 'L', slug: 'lock', expectedVersion: 1 })).rejects.toThrow(
99
- /version.conflict/,
100
- );
101
- });
102
- });
103
-
104
- describe('republishing', () => {
105
- it('rewrites descendants published beneath a parent that was not yet projected', async () => {
106
- const root = await makeNode(ctx, { title: 'Late', slug: 'late' });
107
- const child = await makeNode(ctx, { title: 'Early', slug: 'early', parentId: root.id });
108
- // The child goes live first. Its parent is not in the projection, so it is
109
- // transparent in the published path until it is published too.
110
- await ctx.repos.content.publish(child.id);
111
- expect((await ctx.repos.content.findById(child.id, true))?.permalink).toBe('early');
112
-
113
- await update(root.id, { title: 'Late', slug: 'later', parentId: null });
114
- await ctx.repos.content.publish(root.id);
115
-
116
- expect((await ctx.repos.content.findById(child.id, true))?.permalink).toBe('later/early');
117
- });
118
-
119
- it('skips the subtree walk when a republish leaves the permalink path alone', async () => {
120
- const node = await makeNode(ctx, { title: 'Same', slug: 'same', fields: { body: 'v1' } });
121
- await ctx.repos.content.publish(node.id);
122
- await update(node.id, { title: 'Same', slug: 'same', fields: { body: 'v2' } });
123
-
124
- ctx.queries.length = 0;
125
- await ctx.repos.content.publish(node.id);
126
-
127
- expect(ctx.queries.some((query) => /with recursive/i.test(query))).toBe(false);
128
- expect((await ctx.repos.content.findById(node.id, true))?.fields).toEqual({ body: 'v2' });
129
- });
130
- });
@@ -1,170 +0,0 @@
1
- import { sql } from 'drizzle-orm';
2
- import { afterAll, beforeAll, describe, expect, it } from 'vitest';
3
- import { createRepositoryContext, makeNode, type RepositoryTestContext } from '../src/testing.js';
4
-
5
- let ctx: RepositoryTestContext;
6
-
7
- beforeAll(async () => {
8
- ctx = await createRepositoryContext('query');
9
- await makeNode(ctx, {
10
- title: 'Alpha',
11
- slug: 'alpha',
12
- fields: { body: 'hello world', weight: 5 },
13
- });
14
- await makeNode(ctx, {
15
- title: 'Beta',
16
- slug: 'beta',
17
- fields: { body: 'goodbye world', weight: 10 },
18
- });
19
- await makeNode(ctx, {
20
- title: 'Gamma',
21
- slug: 'gamma',
22
- fields: { body: 'hello again', weight: 15 },
23
- });
24
- });
25
- afterAll(async () => {
26
- await ctx?.close();
27
- });
28
-
29
- const page = (extra: Record<string, unknown> = {}) => ({
30
- spaceId: ctx.spaceId,
31
- typeIds: [ctx.types.page.id],
32
- ...extra,
33
- });
34
-
35
- describe('field filters', () => {
36
- it('filters by equality through jsonb containment', async () => {
37
- const result = await ctx.repos.content.list(
38
- page({ fields: [{ name: 'body', op: 'eq' as const, value: 'hello world' }] }),
39
- { limit: 10, offset: 0 },
40
- );
41
- expect(result.items.map((i) => i.slug)).toEqual(['alpha']);
42
- expect(result.total).toBe(1);
43
- });
44
-
45
- it('filters by substring', async () => {
46
- const result = await ctx.repos.content.list(
47
- page({ fields: [{ name: 'body', op: 'contains' as const, value: 'hello' }] }),
48
- { limit: 10, offset: 0 },
49
- );
50
- expect(result.items.map((i) => i.slug).sort()).toEqual(['alpha', 'gamma']);
51
- });
52
-
53
- it('filters numerically', async () => {
54
- const result = await ctx.repos.content.list(
55
- page({ fields: [{ name: 'weight', op: 'gt' as const, value: 7 }] }),
56
- { limit: 10, offset: 0 },
57
- );
58
- expect(result.items.map((i) => i.slug).sort()).toEqual(['beta', 'gamma']);
59
- });
60
-
61
- /** Only operators the field type declares are accepted; the rest are rejected. */
62
- it('rejects an operator the field type does not declare', async () => {
63
- await expect(
64
- ctx.repos.content.list(
65
- page({ fields: [{ name: 'weight', op: 'contains' as const, value: 'x' }] }),
66
- { limit: 10, offset: 0 },
67
- ),
68
- ).rejects.toThrow(/operator.unsupported/);
69
- });
70
-
71
- it('rejects a field that does not exist on the content type', async () => {
72
- await expect(
73
- ctx.repos.content.list(page({ fields: [{ name: 'nope', op: 'eq' as const, value: 1 }] }), {
74
- limit: 10,
75
- offset: 0,
76
- }),
77
- ).rejects.toThrow(/field.unknown/);
78
- });
79
-
80
- it('escapes LIKE metacharacters instead of letting them act as wildcards', async () => {
81
- await makeNode(ctx, { title: 'Pct', slug: 'pct', fields: { body: '100% sure', weight: 1 } });
82
- const result = await ctx.repos.content.list(
83
- page({ fields: [{ name: 'body', op: 'contains' as const, value: '100%' }] }),
84
- { limit: 10, offset: 0 },
85
- );
86
- expect(result.items.map((i) => i.slug)).toEqual(['pct']);
87
- });
88
-
89
- it('returns the page and its total in one round trip', async () => {
90
- const result = await ctx.repos.content.list(page(), { limit: 2, offset: 0 });
91
- expect(result.items).toHaveLength(2);
92
- expect(result.total).toBeGreaterThanOrEqual(4);
93
- });
94
- });
95
-
96
- describe('index coverage', () => {
97
- /** Every lookup key — `contentId`, `parent`, `permalink`, `space`, `locale` — is indexed. */
98
- it('uses the GiST path index for subtree queries', async () => {
99
- const root = await makeNode(ctx, { title: 'IdxRoot', slug: 'idx-root' });
100
- for (let i = 0; i < 200; i++) {
101
- await makeNode(ctx, { title: `n${i}`, slug: `idx-${i}`, parentId: root.id });
102
- }
103
- await ctx.handle.sql.unsafe('analyze contents');
104
-
105
- const plan = await ctx.handle.db.execute<{ 'QUERY PLAN': string }>(sql`
106
- explain (format text)
107
- select * from contents
108
- where path <@ (select path from contents where id = ${root.id}::uuid)
109
- `);
110
- const text = (plan as unknown as Array<Record<string, string>>)
111
- .map((row) => Object.values(row)[0])
112
- .join('\n');
113
-
114
- expect(text).toMatch(/contents_path_gist_idx/);
115
- });
116
-
117
- it('uses the permalink unique index for delivery lookups', async () => {
118
- await ctx.handle.sql.unsafe('analyze contents');
119
- const plan = await ctx.handle.db.execute<Record<string, string>>(sql`
120
- explain (format text)
121
- select * from contents
122
- where space_id = ${ctx.spaceId}::uuid and locale = 'en' and permalink = 'alpha'
123
- `);
124
- const text = (plan as unknown as Array<Record<string, string>>)
125
- .map((row) => Object.values(row)[0])
126
- .join('\n');
127
-
128
- expect(text).toMatch(/contents_permalink_key/);
129
- });
130
- });
131
-
132
- describe('full-text search', () => {
133
- it('matches on the generated tsvector', async () => {
134
- const result = await ctx.repos.content.list(page({ search: 'goodbye' }), {
135
- limit: 10,
136
- offset: 0,
137
- });
138
- expect(result.items.map((i) => i.slug)).toEqual(['beta']);
139
- });
140
- });
141
-
142
- describe('paginate', () => {
143
- it('answers a page and the total in one statement, and the true total past the end', async () => {
144
- const { paginate } = await import('../src/pagination.js');
145
- const { users } = await import('../src/schema/index.js');
146
- for (const n of [1, 2, 3]) {
147
- await ctx.repos.users.create({
148
- name: `P${n}`,
149
- email: `p${n}@example.com`,
150
- role: 'editor',
151
- passwordHash: 'x',
152
- });
153
- }
154
- ctx.queries.length = 0;
155
- const first = await paginate(ctx.handle.db, users, {
156
- orderBy: users.createdAt,
157
- pagination: { limit: 2, offset: 0 },
158
- });
159
- expect(first.items).toHaveLength(2);
160
- expect(first.total).toBe(3);
161
- expect(ctx.queries).toHaveLength(1);
162
-
163
- const past = await paginate(ctx.handle.db, users, {
164
- orderBy: users.createdAt,
165
- pagination: { limit: 2, offset: 10 },
166
- });
167
- expect(past.items).toEqual([]);
168
- expect(past.total).toBe(3);
169
- });
170
- });
package/test/role.test.ts DELETED
@@ -1,81 +0,0 @@
1
- import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2
- import { createRepositoryContext, type RepositoryTestContext } from '../src/testing.js';
3
-
4
- let ctx: RepositoryTestContext;
5
-
6
- beforeAll(async () => {
7
- ctx = await createRepositoryContext('role');
8
- });
9
- afterAll(async () => {
10
- await ctx?.close();
11
- });
12
-
13
- const stamp = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
14
-
15
- describe('RoleRepository and the principal', () => {
16
- it('resolves a membership naming a custom role to that role’s grants', async () => {
17
- const role = await ctx.repos.roles.create(ctx.spaceId, {
18
- name: 'Blogger',
19
- machineName: `blogger-${stamp()}`,
20
- permissions: ['space:read', `content:write:${ctx.types.page.id}`],
21
- });
22
- const user = await ctx.repos.users.create({
23
- name: 'Blogger',
24
- email: `blogger-${stamp()}@example.com`,
25
- role: 'editor',
26
- passwordHash: 'x',
27
- });
28
- await ctx.repos.users.grant(user.id, ctx.spaceId, role.machineName);
29
-
30
- const principal = await ctx.repos.users.principal(user.id);
31
- expect(principal?.spaces).toEqual({ [ctx.spaceId]: role.machineName });
32
- expect(principal?.permissions).toEqual({ [ctx.spaceId]: role.permissions });
33
- expect(await ctx.repos.roles.countMembers(ctx.spaceId, role.machineName)).toBe(1);
34
- });
35
-
36
- it('carries no grants for a built-in role, which the auth package knows itself', async () => {
37
- const user = await ctx.repos.users.create({
38
- name: 'Editor',
39
- email: `editor-${stamp()}@example.com`,
40
- role: 'editor',
41
- passwordHash: 'x',
42
- });
43
- await ctx.repos.users.grant(user.id, ctx.spaceId, 'editor');
44
- const principal = await ctx.repos.users.principal(user.id);
45
- expect(principal?.spaces).toEqual({ [ctx.spaceId]: 'editor' });
46
- expect(principal?.permissions).toEqual({});
47
- });
48
-
49
- it('prunes the grants naming a content type from every role, keeping the rest', async () => {
50
- const gone = '99999999-9999-4999-8999-999999999999';
51
- const a = await ctx.repos.roles.create(ctx.spaceId, {
52
- name: 'A',
53
- machineName: `a-${stamp()}`,
54
- permissions: ['space:read', `content:read:${gone}`, `content:write:${ctx.types.page.id}`],
55
- });
56
- const b = await ctx.repos.roles.create(ctx.spaceId, {
57
- name: 'B',
58
- machineName: `b-${stamp()}`,
59
- permissions: ['space:read', 'content:read'],
60
- });
61
-
62
- await ctx.repos.roles.pruneContentType(gone);
63
-
64
- expect((await ctx.repos.roles.findById(a.id))?.permissions).toEqual([
65
- 'space:read',
66
- `content:write:${ctx.types.page.id}`,
67
- ]);
68
- expect((await ctx.repos.roles.findById(b.id))?.permissions).toEqual([
69
- 'space:read',
70
- 'content:read',
71
- ]);
72
- });
73
-
74
- it('refuses two roles with one machine name in a space', async () => {
75
- const machineName = `dup-${stamp()}`;
76
- const make = () =>
77
- ctx.repos.roles.create(ctx.spaceId, { name: 'Dup', machineName, permissions: [] });
78
- await make();
79
- await expect(make()).rejects.toMatchObject({ cause: { code: '23505' } });
80
- });
81
- });
package/test/tree.test.ts DELETED
@@ -1,188 +0,0 @@
1
- import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2
- import { createRepositoryContext, makeNode, type RepositoryTestContext } from '../src/testing.js';
3
-
4
- let ctx: RepositoryTestContext;
5
-
6
- beforeAll(async () => {
7
- ctx = await createRepositoryContext('tree');
8
- });
9
- afterAll(async () => {
10
- await ctx?.close();
11
- });
12
-
13
- describe('permalinks', () => {
14
- it('derives a permalink from the ancestor chain', async () => {
15
- const root = await makeNode(ctx, { title: 'Root', slug: 'root' });
16
- const child = await makeNode(ctx, { title: 'Child', slug: 'child', parentId: root.id });
17
- const grandchild = await makeNode(ctx, { title: 'GC', slug: 'gc', parentId: child.id });
18
-
19
- expect(root.permalink).toBe('root');
20
- expect(child.permalink).toBe('root/child');
21
- expect(grandchild.permalink).toBe('root/child/gc');
22
- });
23
-
24
- it('is transparent for a content type that has no slug', async () => {
25
- const root = await makeNode(ctx, { title: 'Docs', slug: 'docs' });
26
- const folder = await makeNode(ctx, {
27
- title: 'Hidden',
28
- slug: 'hidden',
29
- parentId: root.id,
30
- type: 'folder',
31
- });
32
- const leaf = await makeNode(ctx, { title: 'Leaf', slug: 'leaf', parentId: folder.id });
33
-
34
- // A slug-less type is a structural container, not a page: it holds no permalink of
35
- // its own (which would otherwise collide with its parent's) and is skipped in its
36
- // descendants' paths.
37
- expect(folder.permalink).toBeNull();
38
- expect(folder.permalinkPath).toBe('docs');
39
- expect(leaf.permalink).toBe('docs/leaf');
40
- });
41
-
42
- it('keeps descendants correct when a slug-less level sits mid-chain and a slug changes', async () => {
43
- const root = await makeNode(ctx, { title: 'Site', slug: 'site' });
44
- const folder = await makeNode(ctx, {
45
- title: 'Group',
46
- slug: 'group',
47
- parentId: root.id,
48
- type: 'folder',
49
- });
50
- const leaf = await makeNode(ctx, { title: 'Deep', slug: 'deep', parentId: folder.id });
51
-
52
- expect(leaf.permalink).toBe('site/deep');
53
-
54
- await ctx.repos.content.update(root.id, {
55
- spaceId: ctx.spaceId,
56
- typeId: ctx.types.page.id,
57
- locale: 'en',
58
- parentId: null,
59
- title: 'Site',
60
- slug: 'website',
61
- fields: {},
62
- hasSlug: true,
63
- });
64
-
65
- const [nf, nl] = await Promise.all([
66
- ctx.repos.content.findById(folder.id),
67
- ctx.repos.content.findById(leaf.id),
68
- ]);
69
- expect(nf?.permalink).toBeNull();
70
- expect(nf?.permalinkPath).toBe('website');
71
- expect(nl?.permalink).toBe('website/deep');
72
- });
73
-
74
- /** A deep node's permalink derives from its own parent, not its grandparent. */
75
- it('rewrites the whole subtree when a slug changes — grandchildren included', async () => {
76
- const a = await makeNode(ctx, { title: 'A', slug: 'a' });
77
- const b = await makeNode(ctx, { title: 'B', slug: 'b', parentId: a.id });
78
- const c = await makeNode(ctx, { title: 'C', slug: 'c', parentId: b.id });
79
- const d = await makeNode(ctx, { title: 'D', slug: 'd', parentId: c.id });
80
-
81
- expect(d.permalink).toBe('a/b/c/d');
82
-
83
- await ctx.repos.content.update(a.id, {
84
- spaceId: ctx.spaceId,
85
- typeId: ctx.types.page.id,
86
- locale: 'en',
87
- parentId: null,
88
- title: 'A',
89
- slug: 'alpha',
90
- fields: {},
91
- hasSlug: true,
92
- });
93
-
94
- const [nb, nc, nd] = await Promise.all([
95
- ctx.repos.content.findById(b.id),
96
- ctx.repos.content.findById(c.id),
97
- ctx.repos.content.findById(d.id),
98
- ]);
99
-
100
- expect(nb?.permalink).toBe('alpha/b');
101
- expect(nc?.permalink).toBe('alpha/b/c');
102
- // The bug produced 'alpha/b/d' here — the grandparent's prefix plus the leaf slug.
103
- expect(nd?.permalink).toBe('alpha/b/c/d');
104
- });
105
-
106
- it('rebases paths and permalinks when a subtree is reparented', async () => {
107
- const home = await makeNode(ctx, { title: 'Home', slug: 'home' });
108
- const shop = await makeNode(ctx, { title: 'Shop', slug: 'shop' });
109
- const cat = await makeNode(ctx, { title: 'Cat', slug: 'cat', parentId: home.id });
110
- const item = await makeNode(ctx, { title: 'Item', slug: 'item', parentId: cat.id });
111
-
112
- expect(item.permalink).toBe('home/cat/item');
113
-
114
- await ctx.repos.content.update(cat.id, {
115
- spaceId: ctx.spaceId,
116
- typeId: ctx.types.page.id,
117
- locale: 'en',
118
- parentId: shop.id,
119
- title: 'Cat',
120
- slug: 'cat',
121
- fields: {},
122
- hasSlug: true,
123
- });
124
-
125
- const movedItem = await ctx.repos.content.findById(item.id);
126
- expect(movedItem?.permalink).toBe('shop/cat/item');
127
- // The ltree path must be rebased too, or subtree queries silently miss the node.
128
- const movedCat = await ctx.repos.content.findById(cat.id);
129
- expect(movedItem?.path.startsWith(`${movedCat?.path}.`)).toBe(true);
130
- });
131
-
132
- it('refuses to make a node its own descendant', async () => {
133
- const a = await makeNode(ctx, { title: 'X', slug: 'x' });
134
- const b = await makeNode(ctx, { title: 'Y', slug: 'y', parentId: a.id });
135
-
136
- await expect(
137
- ctx.repos.content.update(a.id, {
138
- spaceId: ctx.spaceId,
139
- typeId: ctx.types.page.id,
140
- locale: 'en',
141
- parentId: b.id,
142
- title: 'X',
143
- slug: 'x',
144
- fields: {},
145
- hasSlug: true,
146
- }),
147
- ).rejects.toThrow(/cycle/);
148
- });
149
-
150
- it('rejects duplicate slugs among root-level siblings (NULLS NOT DISTINCT)', async () => {
151
- await makeNode(ctx, { title: 'Dup', slug: 'duplicate-root' });
152
- await expect(makeNode(ctx, { title: 'Dup2', slug: 'duplicate-root' })).rejects.toThrow();
153
- });
154
- });
155
-
156
- describe('tree loading', () => {
157
- it('returns a whole tree in a single query', async () => {
158
- const root = await makeNode(ctx, { title: 'T', slug: 'tree-root' });
159
- const l1 = await makeNode(ctx, { title: 'L1', slug: 'l1', parentId: root.id });
160
- await makeNode(ctx, { title: 'L2', slug: 'l2', parentId: l1.id });
161
- await makeNode(ctx, { title: 'L1b', slug: 'l1b', parentId: root.id });
162
-
163
- const nodes = await ctx.repos.content.tree(ctx.spaceId, 'en', root.id);
164
-
165
- expect(nodes).toHaveLength(2);
166
- const first = nodes.find((n) => n.content.slug === 'l1');
167
- expect(first?.children.map((c) => c.content.slug)).toEqual(['l2']);
168
- });
169
-
170
- it('reads ancestors straight off the materialised path', async () => {
171
- const a = await makeNode(ctx, { title: 'A', slug: 'anc-a' });
172
- const b = await makeNode(ctx, { title: 'B', slug: 'anc-b', parentId: a.id });
173
- const c = await makeNode(ctx, { title: 'C', slug: 'anc-c', parentId: b.id });
174
-
175
- const ancestors = await ctx.repos.content.ancestors(c.id);
176
- expect(ancestors.map((row) => row.slug)).toEqual(['anc-a', 'anc-b']);
177
- });
178
-
179
- it('deletes a node together with its subtree', async () => {
180
- const root = await makeNode(ctx, { title: 'D', slug: 'del-root' });
181
- const child = await makeNode(ctx, { title: 'D1', slug: 'del-1', parentId: root.id });
182
- await makeNode(ctx, { title: 'D2', slug: 'del-2', parentId: child.id });
183
-
184
- const deleted = await ctx.repos.content.delete(root.id);
185
- expect(deleted).toBe(3);
186
- expect(await ctx.repos.content.findById(child.id)).toBeNull();
187
- });
188
- });