@manablox/db 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/dist/index-Cyf_N5K3.d.ts +658 -0
- package/dist/index-rZ24t-Ln.d.ts +4338 -0
- package/dist/index.d.ts +123 -0
- package/dist/index.js +60 -0
- package/dist/repositories-DYjzuuF6.js +1533 -0
- package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
- package/dist/schema-Bb4p16Yz.js +539 -0
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +2 -0
- package/dist/testing.d.ts +77 -0
- package/dist/testing.js +217 -0
- package/migrations/0005_menus.sql +44 -0
- package/migrations/0006_roles.sql +13 -0
- package/migrations/0007_apikey-permissions.sql +4 -0
- package/migrations/0008_workflows.sql +49 -0
- package/migrations/meta/0005_snapshot.json +2504 -0
- package/migrations/meta/0006_snapshot.json +2605 -0
- package/migrations/meta/0007_snapshot.json +2605 -0
- package/migrations/meta/0008_snapshot.json +2986 -0
- package/migrations/meta/_journal.json +28 -0
- package/package.json +22 -9
- package/drizzle.config.ts +0 -11
- package/src/bootstrap.ts +0 -13
- package/src/cli/migrate.ts +0 -24
- package/src/client.ts +0 -37
- package/src/columns.ts +0 -39
- package/src/index.ts +0 -13
- package/src/query.ts +0 -217
- package/src/repositories/asset-usage.ts +0 -166
- package/src/repositories/asset.ts +0 -189
- package/src/repositories/content-type.ts +0 -116
- package/src/repositories/content.ts +0 -758
- package/src/repositories/index.ts +0 -28
- package/src/repositories/space.ts +0 -78
- package/src/repositories/user.ts +0 -134
- package/src/schema.ts +0 -513
- package/test/asset-usage.test.ts +0 -101
- package/test/helpers.ts +0 -153
- package/test/publish.test.ts +0 -102
- package/test/query.test.ts +0 -140
- package/test/tree.test.ts +0 -188
- package/tsconfig.json +0 -4
- package/vitest.config.ts +0 -12
package/test/helpers.ts
DELETED
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
import { dirname, resolve } from 'node:path';
|
|
2
|
-
import { fileURLToPath } from 'node:url';
|
|
3
|
-
import {
|
|
4
|
-
type ContentTypeDefinition,
|
|
5
|
-
ContentTypeRegistry,
|
|
6
|
-
defineContentType,
|
|
7
|
-
defineFieldType,
|
|
8
|
-
FieldTypeRegistry,
|
|
9
|
-
type StandardSchemaV1,
|
|
10
|
-
} from '@manablox/core';
|
|
11
|
-
import { migrate } from 'drizzle-orm/postgres-js/migrator';
|
|
12
|
-
import postgres from 'postgres';
|
|
13
|
-
import { applyBootstrapSql } from '../src/bootstrap.js';
|
|
14
|
-
import { createDatabase, type DatabaseHandle } from '../src/client.js';
|
|
15
|
-
import { createRepositories, type Repositories } from '../src/repositories/index.js';
|
|
16
|
-
|
|
17
|
-
const MIGRATIONS = resolve(dirname(fileURLToPath(import.meta.url)), '../migrations');
|
|
18
|
-
/**
|
|
19
|
-
* Only ever connected to in order to `create database`: every test file works in its own
|
|
20
|
-
* fresh database and drops it again, so this points at a Postgres *server*, not at data
|
|
21
|
-
* the tests touch. That is why falling back to `DATABASE_URL` is safe — it means the
|
|
22
|
-
* suite runs wherever the app itself runs (a container, CI, the host) without a second
|
|
23
|
-
* variable that has to be kept in step. `TEST_DATABASE_URL` still overrides it.
|
|
24
|
-
*/
|
|
25
|
-
const ADMIN_URL =
|
|
26
|
-
process.env.TEST_DATABASE_URL ??
|
|
27
|
-
process.env.DATABASE_URL ??
|
|
28
|
-
'postgres://manablox:manablox@localhost:5432/manablox';
|
|
29
|
-
|
|
30
|
-
const anySchema = <T>(): StandardSchemaV1<unknown, T> => ({
|
|
31
|
-
'~standard': { version: 1, vendor: 'test', validate: (value) => ({ value: value as T }) },
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
export const stringField = defineFieldType({
|
|
35
|
-
name: 'string',
|
|
36
|
-
label: 'Text',
|
|
37
|
-
settingsSchema: anySchema<Record<string, unknown>>(),
|
|
38
|
-
valueSchema: () => anySchema<string>(),
|
|
39
|
-
defaultValue: () => '',
|
|
40
|
-
storage: { kind: 'jsonb', index: 'btree' },
|
|
41
|
-
filters: ['eq', 'neq', 'contains', 'startsWith', 'in', 'isNull', 'isNotNull'],
|
|
42
|
-
graphql: { type: { kind: 'scalar', name: 'String' } },
|
|
43
|
-
search: (value) => (typeof value === 'string' ? value : null),
|
|
44
|
-
admin: { input: 'string' },
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
export const numberField = defineFieldType({
|
|
48
|
-
name: 'number',
|
|
49
|
-
label: 'Number',
|
|
50
|
-
settingsSchema: anySchema<Record<string, unknown>>(),
|
|
51
|
-
valueSchema: () => anySchema<number>(),
|
|
52
|
-
defaultValue: () => 0,
|
|
53
|
-
storage: { kind: 'jsonb', index: 'btree' },
|
|
54
|
-
filters: ['eq', 'lt', 'lte', 'gt', 'gte'],
|
|
55
|
-
graphql: { type: { kind: 'scalar', name: 'Float' } },
|
|
56
|
-
admin: { input: 'number' },
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
export interface TestContext {
|
|
60
|
-
handle: DatabaseHandle;
|
|
61
|
-
repos: Repositories;
|
|
62
|
-
registry: ContentTypeRegistry;
|
|
63
|
-
types: { page: ContentTypeDefinition; folder: ContentTypeDefinition };
|
|
64
|
-
spaceId: string;
|
|
65
|
-
close: () => Promise<void>;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Spins up an isolated database per suite, migrated from the real migration files. */
|
|
69
|
-
export async function createTestContext(name: string): Promise<TestContext> {
|
|
70
|
-
const dbName = `manablox_test_${name}_${Date.now().toString(36)}`;
|
|
71
|
-
const admin = postgres(ADMIN_URL, { max: 1 });
|
|
72
|
-
await admin.unsafe(`create database "${dbName}"`);
|
|
73
|
-
await admin.end();
|
|
74
|
-
|
|
75
|
-
const url = ADMIN_URL.replace(/\/[^/]*$/, `/${dbName}`);
|
|
76
|
-
const handle = createDatabase({ url, max: 4 });
|
|
77
|
-
await applyBootstrapSql(handle.sql);
|
|
78
|
-
await migrate(handle.db, { migrationsFolder: MIGRATIONS });
|
|
79
|
-
|
|
80
|
-
const fieldTypes = new FieldTypeRegistry();
|
|
81
|
-
fieldTypes.register(stringField);
|
|
82
|
-
fieldTypes.register(numberField);
|
|
83
|
-
|
|
84
|
-
const page = defineContentType({
|
|
85
|
-
name: 'page',
|
|
86
|
-
fields: [
|
|
87
|
-
{ name: 'body', type: 'string' },
|
|
88
|
-
{ name: 'weight', type: 'number' },
|
|
89
|
-
],
|
|
90
|
-
});
|
|
91
|
-
// A type without a slug: it must be transparent in descendants' permalinks.
|
|
92
|
-
const folder = defineContentType({
|
|
93
|
-
name: 'folder',
|
|
94
|
-
hasSlug: false,
|
|
95
|
-
fields: [{ name: 'note', type: 'string' }],
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
const registry = new ContentTypeRegistry(fieldTypes);
|
|
99
|
-
registry.setAll([page, folder]);
|
|
100
|
-
registry.validate();
|
|
101
|
-
|
|
102
|
-
const repos = createRepositories(handle.db, registry);
|
|
103
|
-
const space = await repos.spaces.create({
|
|
104
|
-
name: 'Test',
|
|
105
|
-
machineName: 'test',
|
|
106
|
-
url: 'http://localhost:3002',
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
return {
|
|
110
|
-
handle,
|
|
111
|
-
repos,
|
|
112
|
-
registry,
|
|
113
|
-
types: { page, folder },
|
|
114
|
-
spaceId: space.id,
|
|
115
|
-
close: async () => {
|
|
116
|
-
await handle.close();
|
|
117
|
-
const cleanup = postgres(ADMIN_URL, { max: 1 });
|
|
118
|
-
await cleanup.unsafe(`drop database if exists "${dbName}" with (force)`);
|
|
119
|
-
await cleanup.end();
|
|
120
|
-
},
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** Convenience creator so tests read as tree shapes rather than field soup. */
|
|
125
|
-
export async function makeNode(
|
|
126
|
-
ctx: TestContext,
|
|
127
|
-
options: {
|
|
128
|
-
title: string;
|
|
129
|
-
slug: string;
|
|
130
|
-
parentId?: string | null;
|
|
131
|
-
type?: 'page' | 'folder';
|
|
132
|
-
fields?: Record<string, unknown>;
|
|
133
|
-
},
|
|
134
|
-
) {
|
|
135
|
-
const type = ctx.types[options.type ?? 'page'];
|
|
136
|
-
// The content service derives `searchText` from each field type's `search()`
|
|
137
|
-
// contribution; the repository stores what it is given. Mirror that here.
|
|
138
|
-
const searchText = Object.values(options.fields ?? {})
|
|
139
|
-
.filter((value): value is string => typeof value === 'string')
|
|
140
|
-
.join(' ');
|
|
141
|
-
|
|
142
|
-
return ctx.repos.content.create({
|
|
143
|
-
searchText,
|
|
144
|
-
spaceId: ctx.spaceId,
|
|
145
|
-
typeId: type.id,
|
|
146
|
-
locale: 'en',
|
|
147
|
-
parentId: options.parentId ?? null,
|
|
148
|
-
title: options.title,
|
|
149
|
-
slug: options.slug,
|
|
150
|
-
fields: options.fields ?? {},
|
|
151
|
-
hasSlug: type.hasSlug,
|
|
152
|
-
});
|
|
153
|
-
}
|
package/test/publish.test.ts
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
2
|
-
import { createTestContext, makeNode, type TestContext } from './helpers.js';
|
|
3
|
-
|
|
4
|
-
let ctx: TestContext;
|
|
5
|
-
|
|
6
|
-
beforeAll(async () => {
|
|
7
|
-
ctx = await createTestContext('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
|
-
});
|
package/test/query.test.ts
DELETED
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
import { sql } from 'drizzle-orm';
|
|
2
|
-
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
3
|
-
import { createTestContext, makeNode, type TestContext } from './helpers.js';
|
|
4
|
-
|
|
5
|
-
let ctx: TestContext;
|
|
6
|
-
|
|
7
|
-
beforeAll(async () => {
|
|
8
|
-
ctx = await createTestContext('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
|
-
});
|
package/test/tree.test.ts
DELETED
|
@@ -1,188 +0,0 @@
|
|
|
1
|
-
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
2
|
-
import { createTestContext, makeNode, type TestContext } from './helpers.js';
|
|
3
|
-
|
|
4
|
-
let ctx: TestContext;
|
|
5
|
-
|
|
6
|
-
beforeAll(async () => {
|
|
7
|
-
ctx = await createTestContext('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
|
-
});
|
package/tsconfig.json
DELETED
package/vitest.config.ts
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { defineConfig } from 'vitest/config';
|
|
2
|
-
|
|
3
|
-
export default defineConfig({
|
|
4
|
-
test: {
|
|
5
|
-
environment: 'node',
|
|
6
|
-
include: ['test/**/*.test.ts'],
|
|
7
|
-
testTimeout: 60_000,
|
|
8
|
-
hookTimeout: 60_000,
|
|
9
|
-
// Each suite owns a database; running them in one process keeps creation cheap.
|
|
10
|
-
fileParallelism: false,
|
|
11
|
-
},
|
|
12
|
-
});
|