@manablox/db 0.2.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/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/package.json +18 -10
- package/drizzle.config.ts +0 -11
- package/src/bootstrap.ts +0 -13
- package/src/cli/create-db.ts +0 -30
- package/src/cli/migrate.ts +0 -17
- package/src/client.ts +0 -44
- package/src/columns.ts +0 -39
- package/src/errors.ts +0 -50
- package/src/index.ts +0 -19
- package/src/migrate.ts +0 -21
- package/src/pagination.ts +0 -52
- package/src/query.ts +0 -213
- package/src/repositories/asset-usage.ts +0 -166
- package/src/repositories/asset.ts +0 -181
- package/src/repositories/content-type.ts +0 -116
- package/src/repositories/content.ts +0 -811
- package/src/repositories/index.ts +0 -40
- package/src/repositories/menu.ts +0 -235
- package/src/repositories/role.ts +0 -85
- package/src/repositories/space.ts +0 -83
- package/src/repositories/user.ts +0 -280
- package/src/repositories/webhook.ts +0 -46
- package/src/repositories/workflow.ts +0 -306
- package/src/schema/assets.ts +0 -108
- package/src/schema/auth.ts +0 -166
- package/src/schema/content-types.ts +0 -31
- package/src/schema/content.ts +0 -133
- package/src/schema/index.ts +0 -38
- package/src/schema/menus.ts +0 -61
- package/src/schema/relations.ts +0 -64
- package/src/schema/spaces.ts +0 -20
- package/src/schema/webhooks.ts +0 -46
- package/src/schema/workflows.ts +0 -92
- package/src/testing-fixtures.ts +0 -139
- package/src/testing.ts +0 -105
- package/test/asset-usage.test.ts +0 -101
- package/test/menu.test.ts +0 -126
- package/test/publish.test.ts +0 -130
- package/test/query.test.ts +0 -170
- package/test/role.test.ts +0 -81
- package/test/tree.test.ts +0 -188
- package/test/user.test.ts +0 -126
- package/test/webhook.test.ts +0 -48
- package/tsconfig.json +0 -4
- package/vitest.config.ts +0 -10
package/src/schema/workflows.ts
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
WorkflowCursor,
|
|
3
|
-
WorkflowRunContext,
|
|
4
|
-
WorkflowRunStatus,
|
|
5
|
-
WorkflowStep,
|
|
6
|
-
WorkflowStepLog,
|
|
7
|
-
WorkflowTrigger,
|
|
8
|
-
} from '@manablox/core';
|
|
9
|
-
import { sql } from 'drizzle-orm';
|
|
10
|
-
import { boolean, index, jsonb, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
|
11
|
-
import { users } from './auth.js';
|
|
12
|
-
import { spaces } from './spaces.js';
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* A workflow: something that happens when content changes, or on a schedule. The
|
|
16
|
-
* trigger and the steps are documents rather than rows — a workflow is edited and saved
|
|
17
|
-
* as one thing, and a step type added later needs no migration.
|
|
18
|
-
*/
|
|
19
|
-
export const workflows = pgTable(
|
|
20
|
-
'workflows',
|
|
21
|
-
{
|
|
22
|
-
id: uuid().primaryKey().defaultRandom(),
|
|
23
|
-
spaceId: uuid()
|
|
24
|
-
.notNull()
|
|
25
|
-
.references(() => spaces.id, { onDelete: 'cascade' }),
|
|
26
|
-
name: text().notNull(),
|
|
27
|
-
description: text(),
|
|
28
|
-
enabled: boolean().notNull().default(false),
|
|
29
|
-
trigger: jsonb().$type<WorkflowTrigger>().notNull(),
|
|
30
|
-
steps: jsonb().$type<WorkflowStep[]>().notNull().default(sql`'[]'::jsonb`),
|
|
31
|
-
/**
|
|
32
|
-
* The minute a scheduled workflow was last claimed for, so two processes ticking at
|
|
33
|
-
* once start one run between them rather than one each.
|
|
34
|
-
*/
|
|
35
|
-
lastScheduledAt: timestamp({ withTimezone: true }),
|
|
36
|
-
lastRunAt: timestamp({ withTimezone: true }),
|
|
37
|
-
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
38
|
-
updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
39
|
-
},
|
|
40
|
-
(table) => [index('workflows_space_idx').on(table.spaceId, table.enabled)],
|
|
41
|
-
);
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* One execution of a workflow, with everything needed to continue it after a pause: the
|
|
45
|
-
* context the templates read, the index of the next step and the log so far.
|
|
46
|
-
*/
|
|
47
|
-
export const workflowRuns = pgTable(
|
|
48
|
-
'workflow_runs',
|
|
49
|
-
{
|
|
50
|
-
id: uuid().primaryKey().defaultRandom(),
|
|
51
|
-
workflowId: uuid()
|
|
52
|
-
.notNull()
|
|
53
|
-
.references(() => workflows.id, { onDelete: 'cascade' }),
|
|
54
|
-
spaceId: uuid()
|
|
55
|
-
.notNull()
|
|
56
|
-
.references(() => spaces.id, { onDelete: 'cascade' }),
|
|
57
|
-
/** The event name, or `schedule` / `manual`. */
|
|
58
|
-
trigger: text().notNull(),
|
|
59
|
-
status: text().$type<WorkflowRunStatus>().notNull().default('queued'),
|
|
60
|
-
context: jsonb().$type<WorkflowRunContext>().notNull(),
|
|
61
|
-
/** Where to continue; a paused run resumes here. See `WorkflowCursor`. */
|
|
62
|
-
cursor: jsonb().$type<WorkflowCursor>().notNull().default(sql`'[]'::jsonb`),
|
|
63
|
-
log: jsonb().$type<WorkflowStepLog[]>().notNull().default(sql`'[]'::jsonb`),
|
|
64
|
-
error: text(),
|
|
65
|
-
/** Set while `waiting`: when the scheduler should pick the run up again. */
|
|
66
|
-
resumeAt: timestamp({ withTimezone: true }),
|
|
67
|
-
startedAt: timestamp({ withTimezone: true }),
|
|
68
|
-
finishedAt: timestamp({ withTimezone: true }),
|
|
69
|
-
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
70
|
-
},
|
|
71
|
-
(table) => [
|
|
72
|
-
index('workflow_runs_workflow_idx').on(table.workflowId, table.createdAt),
|
|
73
|
-
index('workflow_runs_resume_idx').on(table.status, table.resumeAt),
|
|
74
|
-
],
|
|
75
|
-
);
|
|
76
|
-
|
|
77
|
-
/** A browser a user allowed push notifications in; one row per device. */
|
|
78
|
-
export const pushSubscriptions = pgTable(
|
|
79
|
-
'push_subscriptions',
|
|
80
|
-
{
|
|
81
|
-
id: uuid().primaryKey().defaultRandom(),
|
|
82
|
-
userId: uuid()
|
|
83
|
-
.notNull()
|
|
84
|
-
.references(() => users.id, { onDelete: 'cascade' }),
|
|
85
|
-
endpoint: text().notNull().unique('push_subscriptions_endpoint_key'),
|
|
86
|
-
keys: jsonb().$type<{ p256dh: string; auth: string }>().notNull(),
|
|
87
|
-
userAgent: text(),
|
|
88
|
-
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
89
|
-
lastUsedAt: timestamp({ withTimezone: true }),
|
|
90
|
-
},
|
|
91
|
-
(table) => [index('push_subscriptions_user_idx').on(table.userId)],
|
|
92
|
-
);
|
package/src/testing-fixtures.ts
DELETED
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Fixtures for a repository-level test: two plain field types, a `page` and a `folder`
|
|
3
|
-
* content type, a migrated database with one space, and a node maker. Shared through
|
|
4
|
-
* `@manablox/db/testing` so a suite in another package can start from the same world.
|
|
5
|
-
*/
|
|
6
|
-
import {
|
|
7
|
-
type ContentTypeDefinition,
|
|
8
|
-
ContentTypeRegistry,
|
|
9
|
-
defineContentType,
|
|
10
|
-
defineFieldType,
|
|
11
|
-
FieldTypeRegistry,
|
|
12
|
-
type StandardSchemaV1,
|
|
13
|
-
} from '@manablox/core';
|
|
14
|
-
import { createDatabase, type DatabaseHandle } from './client.js';
|
|
15
|
-
import { createRepositories, type Repositories } from './repositories/index.js';
|
|
16
|
-
import { createTestDatabase } from './testing.js';
|
|
17
|
-
|
|
18
|
-
const anySchema = <T>(): StandardSchemaV1<unknown, T> => ({
|
|
19
|
-
'~standard': { version: 1, vendor: 'test', validate: (value) => ({ value: value as T }) },
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
export const stringField = defineFieldType({
|
|
23
|
-
name: 'string',
|
|
24
|
-
label: 'Text',
|
|
25
|
-
settingsSchema: anySchema<Record<string, unknown>>(),
|
|
26
|
-
valueSchema: () => anySchema<string>(),
|
|
27
|
-
defaultValue: () => '',
|
|
28
|
-
storage: { kind: 'jsonb', index: 'btree' },
|
|
29
|
-
filters: ['eq', 'neq', 'contains', 'startsWith', 'in', 'isNull', 'isNotNull'],
|
|
30
|
-
graphql: { type: { kind: 'scalar', name: 'String' } },
|
|
31
|
-
search: (value) => (typeof value === 'string' ? value : null),
|
|
32
|
-
admin: { input: 'string' },
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
export const numberField = defineFieldType({
|
|
36
|
-
name: 'number',
|
|
37
|
-
label: 'Number',
|
|
38
|
-
settingsSchema: anySchema<Record<string, unknown>>(),
|
|
39
|
-
valueSchema: () => anySchema<number>(),
|
|
40
|
-
defaultValue: () => 0,
|
|
41
|
-
storage: { kind: 'jsonb', index: 'btree' },
|
|
42
|
-
filters: ['eq', 'lt', 'lte', 'gt', 'gte'],
|
|
43
|
-
graphql: { type: { kind: 'scalar', name: 'Float' } },
|
|
44
|
-
admin: { input: 'number' },
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
export interface RepositoryTestContext {
|
|
48
|
-
handle: DatabaseHandle;
|
|
49
|
-
/** Every statement sent to Postgres, so a test can assert what a call cost. */
|
|
50
|
-
queries: string[];
|
|
51
|
-
repos: Repositories;
|
|
52
|
-
registry: ContentTypeRegistry;
|
|
53
|
-
types: { page: ContentTypeDefinition; folder: ContentTypeDefinition };
|
|
54
|
-
spaceId: string;
|
|
55
|
-
close: () => Promise<void>;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Spins up an isolated database per suite, migrated from the real migration files. */
|
|
59
|
-
export async function createRepositoryContext(name: string): Promise<RepositoryTestContext> {
|
|
60
|
-
const database = await createTestDatabase(name);
|
|
61
|
-
const queries: string[] = [];
|
|
62
|
-
const handle = createDatabase(
|
|
63
|
-
{ url: database.url, max: 4 },
|
|
64
|
-
{ onQuery: (query) => queries.push(query) },
|
|
65
|
-
);
|
|
66
|
-
|
|
67
|
-
const fieldTypes = new FieldTypeRegistry();
|
|
68
|
-
fieldTypes.register(stringField);
|
|
69
|
-
fieldTypes.register(numberField);
|
|
70
|
-
|
|
71
|
-
const page = defineContentType({
|
|
72
|
-
name: 'page',
|
|
73
|
-
fields: [
|
|
74
|
-
{ name: 'body', type: 'string' },
|
|
75
|
-
{ name: 'weight', type: 'number' },
|
|
76
|
-
],
|
|
77
|
-
});
|
|
78
|
-
// A type without a slug: it must be transparent in descendants' permalinks.
|
|
79
|
-
const folder = defineContentType({
|
|
80
|
-
name: 'folder',
|
|
81
|
-
hasSlug: false,
|
|
82
|
-
fields: [{ name: 'note', type: 'string' }],
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
const registry = new ContentTypeRegistry(fieldTypes);
|
|
86
|
-
registry.setAll([page, folder]);
|
|
87
|
-
registry.validate();
|
|
88
|
-
|
|
89
|
-
const repos = createRepositories(handle.db, registry);
|
|
90
|
-
const space = await repos.spaces.create({
|
|
91
|
-
name: 'Test',
|
|
92
|
-
machineName: 'test',
|
|
93
|
-
url: 'http://localhost:3002',
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
return {
|
|
97
|
-
handle,
|
|
98
|
-
queries,
|
|
99
|
-
repos,
|
|
100
|
-
registry,
|
|
101
|
-
types: { page, folder },
|
|
102
|
-
spaceId: space.id,
|
|
103
|
-
close: async () => {
|
|
104
|
-
await handle.close();
|
|
105
|
-
await database.drop();
|
|
106
|
-
},
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** Convenience creator so tests read as tree shapes rather than field soup. */
|
|
111
|
-
export async function makeNode(
|
|
112
|
-
ctx: RepositoryTestContext,
|
|
113
|
-
options: {
|
|
114
|
-
title: string;
|
|
115
|
-
slug: string;
|
|
116
|
-
parentId?: string | null;
|
|
117
|
-
type?: 'page' | 'folder';
|
|
118
|
-
fields?: Record<string, unknown>;
|
|
119
|
-
},
|
|
120
|
-
) {
|
|
121
|
-
const type = ctx.types[options.type ?? 'page'];
|
|
122
|
-
// The content service derives `searchText` from each field type's `search()`
|
|
123
|
-
// contribution; the repository stores what it is given. Mirror that here.
|
|
124
|
-
const searchText = Object.values(options.fields ?? {})
|
|
125
|
-
.filter((value): value is string => typeof value === 'string')
|
|
126
|
-
.join(' ');
|
|
127
|
-
|
|
128
|
-
return ctx.repos.content.create({
|
|
129
|
-
searchText,
|
|
130
|
-
spaceId: ctx.spaceId,
|
|
131
|
-
typeId: type.id,
|
|
132
|
-
locale: 'en',
|
|
133
|
-
parentId: options.parentId ?? null,
|
|
134
|
-
title: options.title,
|
|
135
|
-
slug: options.slug,
|
|
136
|
-
fields: options.fields ?? {},
|
|
137
|
-
hasSlug: type.hasSlug,
|
|
138
|
-
});
|
|
139
|
-
}
|
package/src/testing.ts
DELETED
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
import { readdirSync, readFileSync } from 'node:fs';
|
|
3
|
-
import { dirname, join, resolve } from 'node:path';
|
|
4
|
-
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import { migrate } from 'drizzle-orm/postgres-js/migrator';
|
|
6
|
-
import postgres from 'postgres';
|
|
7
|
-
import { applyBootstrapSql } from './bootstrap.js';
|
|
8
|
-
import { createDatabase } from './client.js';
|
|
9
|
-
|
|
10
|
-
const MIGRATIONS = resolve(dirname(fileURLToPath(import.meta.url)), '../migrations');
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Only ever connected to in order to `create database`: every test file works in its own
|
|
14
|
-
* fresh database and drops it again, so this points at a Postgres *server*, not at data
|
|
15
|
-
* the tests touch. That is why falling back to `DATABASE_URL` is safe — it means the
|
|
16
|
-
* suite runs wherever the app itself runs (a container, CI, the host) without a second
|
|
17
|
-
* variable that has to be kept in step. `TEST_DATABASE_URL` still overrides it.
|
|
18
|
-
*/
|
|
19
|
-
export const TEST_ADMIN_URL =
|
|
20
|
-
process.env.TEST_DATABASE_URL ??
|
|
21
|
-
process.env.DATABASE_URL ??
|
|
22
|
-
'postgres://manablox:manablox@localhost:5432/manablox';
|
|
23
|
-
|
|
24
|
-
/** A fingerprint of the migration files, so a schema change gets a fresh template. */
|
|
25
|
-
function migrationsFingerprint(): string {
|
|
26
|
-
const hash = createHash('sha1');
|
|
27
|
-
for (const file of readdirSync(MIGRATIONS).sort()) {
|
|
28
|
-
if (file.endsWith('.sql')) hash.update(readFileSync(join(MIGRATIONS, file)));
|
|
29
|
-
}
|
|
30
|
-
return hash.digest('hex').slice(0, 10);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const TEMPLATE = `manablox_test_template_${migrationsFingerprint()}`;
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Makes sure the migrated template exists, once per server.
|
|
37
|
-
*
|
|
38
|
-
* Migrating takes a second or two; `create database … template …` takes milliseconds,
|
|
39
|
-
* so every suite clones the template instead of migrating from scratch. Suites run in
|
|
40
|
-
* parallel across processes, so the check-and-create is serialised on a session-level
|
|
41
|
-
* advisory lock — the second process finds the template already there.
|
|
42
|
-
*/
|
|
43
|
-
async function ensureTemplate(admin: postgres.Sql): Promise<void> {
|
|
44
|
-
await admin.unsafe(`select pg_advisory_lock(hashtext('${TEMPLATE}'))`);
|
|
45
|
-
try {
|
|
46
|
-
const [row] = await admin.unsafe(
|
|
47
|
-
`select 1 as found from pg_database where datname = '${TEMPLATE}'`,
|
|
48
|
-
);
|
|
49
|
-
if (row) return;
|
|
50
|
-
|
|
51
|
-
await admin.unsafe(`create database "${TEMPLATE}"`);
|
|
52
|
-
const handle = createDatabase({ url: withDatabase(TEST_ADMIN_URL, TEMPLATE), max: 1 });
|
|
53
|
-
try {
|
|
54
|
-
await applyBootstrapSql(handle.sql);
|
|
55
|
-
await migrate(handle.db, { migrationsFolder: MIGRATIONS });
|
|
56
|
-
} finally {
|
|
57
|
-
await handle.close();
|
|
58
|
-
}
|
|
59
|
-
} finally {
|
|
60
|
-
await admin.unsafe(`select pg_advisory_unlock(hashtext('${TEMPLATE}'))`);
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function withDatabase(url: string, name: string): string {
|
|
65
|
-
return url.replace(/\/[^/?]*(\?.*)?$/, `/${name}$1`);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export interface TestDatabase {
|
|
69
|
-
name: string;
|
|
70
|
-
url: string;
|
|
71
|
-
/** Drops the database. Safe to call once every connection to it is closed. */
|
|
72
|
-
drop: () => Promise<void>;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* A fresh, migrated database for one test suite, cloned from the template.
|
|
77
|
-
*
|
|
78
|
-
* `prefix` names the suite in `pg_database`, which is what you grep for when a run was
|
|
79
|
-
* interrupted and left a database behind.
|
|
80
|
-
*/
|
|
81
|
-
export async function createTestDatabase(prefix: string): Promise<TestDatabase> {
|
|
82
|
-
const name = `manablox_test_${prefix}_${Date.now().toString(36)}_${process.pid.toString(36)}`;
|
|
83
|
-
const admin = postgres(TEST_ADMIN_URL, { max: 1 });
|
|
84
|
-
try {
|
|
85
|
-
await ensureTemplate(admin);
|
|
86
|
-
await admin.unsafe(`create database "${name}" template "${TEMPLATE}"`);
|
|
87
|
-
} finally {
|
|
88
|
-
await admin.end();
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
return {
|
|
92
|
-
name,
|
|
93
|
-
url: withDatabase(TEST_ADMIN_URL, name),
|
|
94
|
-
drop: async () => {
|
|
95
|
-
const cleanup = postgres(TEST_ADMIN_URL, { max: 1 });
|
|
96
|
-
try {
|
|
97
|
-
await cleanup.unsafe(`drop database if exists "${name}" with (force)`);
|
|
98
|
-
} finally {
|
|
99
|
-
await cleanup.end();
|
|
100
|
-
}
|
|
101
|
-
},
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export * from './testing-fixtures.js';
|
package/test/asset-usage.test.ts
DELETED
|
@@ -1,101 +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
|
-
let assetA: string;
|
|
6
|
-
let assetB: string;
|
|
7
|
-
|
|
8
|
-
beforeAll(async () => {
|
|
9
|
-
ctx = await createRepositoryContext('asset_usage');
|
|
10
|
-
assetA = (await createAsset('a.jpg')).id;
|
|
11
|
-
assetB = (await createAsset('b.jpg')).id;
|
|
12
|
-
});
|
|
13
|
-
afterAll(async () => {
|
|
14
|
-
await ctx?.close();
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
const createAsset = (filename: string) =>
|
|
18
|
-
ctx.repos.assets.create({
|
|
19
|
-
spaceId: ctx.spaceId,
|
|
20
|
-
driver: 'local',
|
|
21
|
-
key: `k/${filename}`,
|
|
22
|
-
filename,
|
|
23
|
-
name: filename,
|
|
24
|
-
mimeType: 'image/jpeg',
|
|
25
|
-
size: 10,
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
const published = (ids: string[]) => ctx.repos.assetUsages.filterPublished(ids);
|
|
29
|
-
|
|
30
|
-
describe('asset reachability', () => {
|
|
31
|
-
it('a draft reference does not make an asset public', async () => {
|
|
32
|
-
const node = await makeNode(ctx, { title: 'Draft', slug: 'draft' });
|
|
33
|
-
await ctx.repos.assetUsages.recordDraft(node.id, ctx.spaceId, [assetA]);
|
|
34
|
-
|
|
35
|
-
expect([...(await published([assetA]))]).toEqual([]);
|
|
36
|
-
expect(await ctx.repos.assetUsages.forContent(node.id)).toEqual([
|
|
37
|
-
{ assetId: assetA, published: false },
|
|
38
|
-
]);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it('publishing makes exactly the referenced assets public', async () => {
|
|
42
|
-
const node = await makeNode(ctx, { title: 'Live', slug: 'live' });
|
|
43
|
-
await ctx.repos.assetUsages.recordDraft(node.id, ctx.spaceId, [assetA, assetB]);
|
|
44
|
-
await ctx.repos.assetUsages.recordPublished(node.id, ctx.spaceId, [assetA]);
|
|
45
|
-
|
|
46
|
-
const reachable = await published([assetA, assetB]);
|
|
47
|
-
expect(reachable.has(assetA)).toBe(true);
|
|
48
|
-
expect(reachable.has(assetB)).toBe(false);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it('editing a draft does not revoke an asset the live page still shows', async () => {
|
|
52
|
-
const node = await makeNode(ctx, { title: 'Edited', slug: 'edited' });
|
|
53
|
-
await ctx.repos.assetUsages.recordPublished(node.id, ctx.spaceId, [assetA]);
|
|
54
|
-
|
|
55
|
-
// The editor removes the image from the draft but has not published yet.
|
|
56
|
-
await ctx.repos.assetUsages.recordDraft(node.id, ctx.spaceId, []);
|
|
57
|
-
|
|
58
|
-
expect((await published([assetA])).has(assetA)).toBe(true);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('unpublishing the only referencing document revokes the asset', async () => {
|
|
62
|
-
const asset = (await createAsset('lonely.jpg')).id;
|
|
63
|
-
const node = await makeNode(ctx, { title: 'Lonely', slug: 'lonely' });
|
|
64
|
-
|
|
65
|
-
await ctx.repos.assetUsages.recordPublished(node.id, ctx.spaceId, [asset]);
|
|
66
|
-
expect((await published([asset])).has(asset)).toBe(true);
|
|
67
|
-
|
|
68
|
-
await ctx.repos.assetUsages.clearPublished(node.id);
|
|
69
|
-
expect((await published([asset])).has(asset)).toBe(false);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('keeps an asset public while any other published document references it', async () => {
|
|
73
|
-
const asset = (await createAsset('shared.jpg')).id;
|
|
74
|
-
const one = await makeNode(ctx, { title: 'One', slug: 'one' });
|
|
75
|
-
const two = await makeNode(ctx, { title: 'Two', slug: 'two' });
|
|
76
|
-
|
|
77
|
-
await ctx.repos.assetUsages.recordPublished(one.id, ctx.spaceId, [asset]);
|
|
78
|
-
await ctx.repos.assetUsages.recordPublished(two.id, ctx.spaceId, [asset]);
|
|
79
|
-
|
|
80
|
-
await ctx.repos.assetUsages.clearPublished(one.id);
|
|
81
|
-
expect((await published([asset])).has(asset)).toBe(true);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it('republishing without an asset revokes it', async () => {
|
|
85
|
-
const asset = (await createAsset('dropped.jpg')).id;
|
|
86
|
-
const node = await makeNode(ctx, { title: 'Dropped', slug: 'dropped' });
|
|
87
|
-
|
|
88
|
-
await ctx.repos.assetUsages.recordPublished(node.id, ctx.spaceId, [asset]);
|
|
89
|
-
await ctx.repos.assetUsages.recordPublished(node.id, ctx.spaceId, []);
|
|
90
|
-
|
|
91
|
-
expect((await published([asset])).has(asset)).toBe(false);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it('drops every usage when the document is deleted', async () => {
|
|
95
|
-
const node = await makeNode(ctx, { title: 'Doomed', slug: 'doomed' });
|
|
96
|
-
await ctx.repos.assetUsages.recordPublished(node.id, ctx.spaceId, [assetA, assetB]);
|
|
97
|
-
|
|
98
|
-
await ctx.repos.assetUsages.deleteForContent(node.id);
|
|
99
|
-
expect(await ctx.repos.assetUsages.forContent(node.id)).toEqual([]);
|
|
100
|
-
});
|
|
101
|
-
});
|
package/test/menu.test.ts
DELETED
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
2
|
-
import { isUniqueViolation } from '../src/errors.js';
|
|
3
|
-
import { createRepositoryContext, makeNode, type RepositoryTestContext } from '../src/testing.js';
|
|
4
|
-
|
|
5
|
-
let ctx: RepositoryTestContext;
|
|
6
|
-
|
|
7
|
-
beforeAll(async () => {
|
|
8
|
-
ctx = await createRepositoryContext('menu');
|
|
9
|
-
});
|
|
10
|
-
afterAll(async () => {
|
|
11
|
-
await ctx?.close();
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
const menu = (machineName: string) =>
|
|
15
|
-
ctx.repos.menus.create({ spaceId: ctx.spaceId, name: machineName, machineName });
|
|
16
|
-
|
|
17
|
-
describe('menus', () => {
|
|
18
|
-
it('stores a nested tree and reads it back in order', async () => {
|
|
19
|
-
const main = await menu('main');
|
|
20
|
-
const home = await makeNode(ctx, { title: 'Home', slug: 'home' });
|
|
21
|
-
const about = await makeNode(ctx, { title: 'About', slug: 'about' });
|
|
22
|
-
const team = await makeNode(ctx, { title: 'Team', slug: 'team', parentId: about.id });
|
|
23
|
-
|
|
24
|
-
await ctx.repos.menus.setItems(main.id, [
|
|
25
|
-
{ localizationId: home.localizationId },
|
|
26
|
-
{
|
|
27
|
-
localizationId: about.localizationId,
|
|
28
|
-
label: 'Who we are',
|
|
29
|
-
children: [
|
|
30
|
-
{ localizationId: team.localizationId },
|
|
31
|
-
{ url: 'https://jobs.example', label: 'Jobs' },
|
|
32
|
-
],
|
|
33
|
-
},
|
|
34
|
-
]);
|
|
35
|
-
|
|
36
|
-
const tree = await ctx.repos.menus.tree(main.id);
|
|
37
|
-
expect(tree.map((node) => node.item.localizationId)).toEqual([
|
|
38
|
-
home.localizationId,
|
|
39
|
-
about.localizationId,
|
|
40
|
-
]);
|
|
41
|
-
expect(tree[1]?.item.label).toBe('Who we are');
|
|
42
|
-
expect(tree[1]?.children.map((node) => node.item.url)).toEqual([null, 'https://jobs.example']);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it('keeps an entry’s id across saves when it is handed back', async () => {
|
|
46
|
-
const footer = await menu('footer');
|
|
47
|
-
const legal = await makeNode(ctx, { title: 'Legal', slug: 'legal' });
|
|
48
|
-
const [first] = await ctx.repos.menus.setItems(footer.id, [
|
|
49
|
-
{ localizationId: legal.localizationId },
|
|
50
|
-
]);
|
|
51
|
-
const [second] = await ctx.repos.menus.setItems(footer.id, [
|
|
52
|
-
{ id: first?.item.id, localizationId: legal.localizationId, label: 'Imprint' },
|
|
53
|
-
]);
|
|
54
|
-
expect(second?.item.id).toBe(first?.item.id);
|
|
55
|
-
expect(second?.item.label).toBe('Imprint');
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it('resolves each entry to the document of the requested locale, published or draft', async () => {
|
|
59
|
-
const nav = await menu('nav');
|
|
60
|
-
const en = await makeNode(ctx, { title: 'Contact', slug: 'contact' });
|
|
61
|
-
const de = await ctx.repos.content.create({
|
|
62
|
-
spaceId: ctx.spaceId,
|
|
63
|
-
typeId: ctx.types.page.id,
|
|
64
|
-
locale: 'de',
|
|
65
|
-
localizationId: en.localizationId,
|
|
66
|
-
parentId: null,
|
|
67
|
-
title: 'Kontakt',
|
|
68
|
-
slug: 'kontakt',
|
|
69
|
-
fields: {},
|
|
70
|
-
hasSlug: true,
|
|
71
|
-
});
|
|
72
|
-
const onlyEnglish = await makeNode(ctx, { title: 'Blog', slug: 'blog' });
|
|
73
|
-
await ctx.repos.menus.setItems(nav.id, [
|
|
74
|
-
{ localizationId: en.localizationId },
|
|
75
|
-
{ localizationId: onlyEnglish.localizationId },
|
|
76
|
-
{ url: '/rss', label: 'Feed' },
|
|
77
|
-
]);
|
|
78
|
-
|
|
79
|
-
const german = await ctx.repos.menus.resolve(nav, 'de');
|
|
80
|
-
expect(german.map((item) => item.content?.title ?? null)).toEqual(['Kontakt', null, null]);
|
|
81
|
-
expect(german[1]?.localizationId).toBe(onlyEnglish.localizationId);
|
|
82
|
-
|
|
83
|
-
// Nothing is published yet, so the delivery view has no documents at all.
|
|
84
|
-
const live = await ctx.repos.menus.resolve(nav, 'de', true);
|
|
85
|
-
expect(live.map((item) => item.content)).toEqual([null, null, null]);
|
|
86
|
-
|
|
87
|
-
await ctx.repos.content.publish(de.id);
|
|
88
|
-
const afterPublish = await ctx.repos.menus.resolve(nav, 'de', true);
|
|
89
|
-
expect(afterPublish[0]?.content?.title).toBe('Kontakt');
|
|
90
|
-
expect(afterPublish[0]?.content?.status).toBe('published');
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it('removes a document from every menu, sub-entries included', async () => {
|
|
94
|
-
const a = await menu('a');
|
|
95
|
-
const b = await menu('b');
|
|
96
|
-
const parent = await makeNode(ctx, { title: 'Parent', slug: 'parent' });
|
|
97
|
-
const child = await makeNode(ctx, { title: 'Child', slug: 'child', parentId: parent.id });
|
|
98
|
-
await ctx.repos.menus.setItems(a.id, [
|
|
99
|
-
{
|
|
100
|
-
localizationId: parent.localizationId,
|
|
101
|
-
children: [{ localizationId: child.localizationId }],
|
|
102
|
-
},
|
|
103
|
-
]);
|
|
104
|
-
await ctx.repos.menus.setItems(b.id, [{ localizationId: parent.localizationId }]);
|
|
105
|
-
|
|
106
|
-
const referencing = await ctx.repos.menus.menusReferencing(ctx.spaceId, parent.localizationId);
|
|
107
|
-
expect(referencing.map((row) => row.machineName).sort()).toEqual(['a', 'b']);
|
|
108
|
-
|
|
109
|
-
expect(await ctx.repos.menus.removeContent(parent.localizationId)).toBe(2);
|
|
110
|
-
expect(await ctx.repos.menus.items(a.id)).toEqual([]);
|
|
111
|
-
expect(await ctx.repos.menus.items(b.id)).toEqual([]);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it('deletes a menu with its entries and refuses a duplicate technical name', async () => {
|
|
115
|
-
const gone = await menu('gone');
|
|
116
|
-
const page = await makeNode(ctx, { title: 'Gone', slug: 'gone' });
|
|
117
|
-
await ctx.repos.menus.setItems(gone.id, [{ localizationId: page.localizationId }]);
|
|
118
|
-
await ctx.repos.menus.delete(gone.id);
|
|
119
|
-
expect(await ctx.repos.menus.findById(gone.id)).toBeNull();
|
|
120
|
-
expect(await ctx.repos.menus.items(gone.id)).toEqual([]);
|
|
121
|
-
|
|
122
|
-
await expect(menu('main')).rejects.toSatisfy((error: unknown) =>
|
|
123
|
-
isUniqueViolation(error, 'menus_space_machine_name_key'),
|
|
124
|
-
);
|
|
125
|
-
});
|
|
126
|
-
});
|