@materializecms/db 0.0.0-stage → 0.1.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/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/README.md +14 -2
- package/package.json +28 -4
- package/src/activity.d.ts +38 -0
- package/src/activity.js +52 -0
- package/src/auth.d.ts +154 -0
- package/src/auth.js +337 -0
- package/src/comments.d.ts +26 -0
- package/src/comments.js +25 -0
- package/src/content.d.ts +115 -0
- package/src/content.js +314 -0
- package/src/database.d.ts +19 -0
- package/src/database.js +35 -0
- package/src/index.d.ts +8 -0
- package/src/index.js +8 -0
- package/src/media.d.ts +36 -0
- package/src/media.js +47 -0
- package/src/settings.d.ts +7 -0
- package/src/settings.js +21 -0
- package/src/submissions.d.ts +27 -0
- package/src/submissions.js +28 -0
- package/src/tables.d.ts +1855 -0
- package/src/tables.js +316 -0
package/src/content.js
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { and, asc, count, desc, eq, inArray, isNotNull, like, or, sql } from 'drizzle-orm';
|
|
3
|
+
import { documents, revisions } from './tables.js';
|
|
4
|
+
/** Raised when a slug is already used in the same collection and locale. */
|
|
5
|
+
export class SlugTakenError extends Error {
|
|
6
|
+
constructor(slug) {
|
|
7
|
+
super(`The address "${slug}" is already used by another document`);
|
|
8
|
+
this.name = 'SlugTakenError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const REVISION_MERGE_MS = 5 * 60 * 1000;
|
|
12
|
+
function statusOf(row) {
|
|
13
|
+
if (!row.publishedAt)
|
|
14
|
+
return 'draft';
|
|
15
|
+
return row.hasChanges ? 'changed' : 'published';
|
|
16
|
+
}
|
|
17
|
+
/** The first image found in a document's data (fields first, then blocks, in order). */
|
|
18
|
+
export function firstImage(value, depth = 0) {
|
|
19
|
+
if (!value || typeof value !== 'object' || depth > 6)
|
|
20
|
+
return null;
|
|
21
|
+
const record = value;
|
|
22
|
+
if (typeof record.src === 'string' && record.src && 'alt' in record)
|
|
23
|
+
return record.src;
|
|
24
|
+
for (const child of Array.isArray(value) ? value : Object.values(record)) {
|
|
25
|
+
const found = firstImage(child, depth + 1);
|
|
26
|
+
if (found)
|
|
27
|
+
return found;
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
function toDocument(row) {
|
|
32
|
+
return {
|
|
33
|
+
id: row.id,
|
|
34
|
+
collection: row.collection,
|
|
35
|
+
locale: row.locale,
|
|
36
|
+
slug: row.slug,
|
|
37
|
+
title: row.title,
|
|
38
|
+
status: statusOf(row),
|
|
39
|
+
data: row.data,
|
|
40
|
+
publishedData: row.publishedData ?? null,
|
|
41
|
+
createdAt: row.createdAt,
|
|
42
|
+
updatedAt: row.updatedAt,
|
|
43
|
+
publishedAt: row.publishedAt ?? null,
|
|
44
|
+
sourceId: row.sourceId ?? null,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function isUniqueViolation(error) {
|
|
48
|
+
const text = String(error?.cause ?? error);
|
|
49
|
+
return text.includes('UNIQUE constraint failed: documents.collection, documents.locale, documents.slug');
|
|
50
|
+
}
|
|
51
|
+
export function createContentStore(db) {
|
|
52
|
+
async function addRevision(documentId, kind, data, authorId) {
|
|
53
|
+
await db.insert(revisions).values({ id: randomUUID(), documentId, kind, data, createdAt: new Date(), authorId });
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The admin autosaves while people type: consecutive saves by the same person within a few minutes
|
|
57
|
+
* update one revision instead of filling the history with near-identical versions.
|
|
58
|
+
*/
|
|
59
|
+
async function addSaveRevision(documentId, data, authorId) {
|
|
60
|
+
const [last] = await db
|
|
61
|
+
.select()
|
|
62
|
+
.from(revisions)
|
|
63
|
+
.where(eq(revisions.documentId, documentId))
|
|
64
|
+
.orderBy(desc(sql `rowid`))
|
|
65
|
+
.limit(1);
|
|
66
|
+
const now = new Date();
|
|
67
|
+
if (last?.kind === 'save' &&
|
|
68
|
+
last.authorId === (authorId ?? null) &&
|
|
69
|
+
now.getTime() - last.createdAt.getTime() < REVISION_MERGE_MS) {
|
|
70
|
+
await db.update(revisions).set({ data, createdAt: now }).where(eq(revisions.id, last.id));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
await addRevision(documentId, 'save', data, authorId);
|
|
74
|
+
}
|
|
75
|
+
async function write(slug, run) {
|
|
76
|
+
try {
|
|
77
|
+
return await run();
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (slug && isUniqueViolation(error))
|
|
81
|
+
throw new SlugTakenError(slug);
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** The languages of each listed document's group (original plus translations), by group key. */
|
|
86
|
+
async function groupsOf(rows) {
|
|
87
|
+
const keys = [...new Set(rows.map((r) => r.sourceId ?? r.id))];
|
|
88
|
+
const out = new Map();
|
|
89
|
+
if (!keys.length)
|
|
90
|
+
return out;
|
|
91
|
+
const members = await db
|
|
92
|
+
.select({ id: documents.id, sourceId: documents.sourceId, locale: documents.locale })
|
|
93
|
+
.from(documents)
|
|
94
|
+
.where(or(inArray(documents.id, keys), inArray(documents.sourceId, keys)));
|
|
95
|
+
for (const m of members) {
|
|
96
|
+
const key = m.sourceId ?? m.id;
|
|
97
|
+
out.set(key, [...(out.get(key) ?? []), m.locale]);
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
async list(collection, options) {
|
|
103
|
+
const where = and(eq(documents.collection, collection), eq(documents.locale, options.locale), options.publishedOnly ? isNotNull(documents.publishedAt) : undefined, options.search ? like(documents.title, `%${options.search.replace(/[%_]/g, '')}%`) : undefined);
|
|
104
|
+
const rows = await db
|
|
105
|
+
.select()
|
|
106
|
+
.from(documents)
|
|
107
|
+
.where(where)
|
|
108
|
+
.orderBy(desc(documents.updatedAt))
|
|
109
|
+
.limit(Math.min(options.limit ?? 50, 200))
|
|
110
|
+
.offset(options.offset ?? 0);
|
|
111
|
+
const [totalRow] = await db.select({ n: count() }).from(documents).where(where);
|
|
112
|
+
const groups = await groupsOf(rows);
|
|
113
|
+
return {
|
|
114
|
+
total: totalRow?.n ?? 0,
|
|
115
|
+
items: rows.map((r) => ({
|
|
116
|
+
id: r.id,
|
|
117
|
+
title: r.title,
|
|
118
|
+
slug: r.slug,
|
|
119
|
+
status: statusOf(r),
|
|
120
|
+
updatedAt: r.updatedAt,
|
|
121
|
+
publishedAt: r.publishedAt ?? null,
|
|
122
|
+
image: firstImage(r.data),
|
|
123
|
+
locales: (groups.get(r.sourceId ?? r.id) ?? []).filter((l) => l !== r.locale),
|
|
124
|
+
})),
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
/** Every language version of a document, itself included: the original and its translations. */
|
|
128
|
+
async translations(id) {
|
|
129
|
+
const doc = await db.query.documents.findFirst({ where: eq(documents.id, id) });
|
|
130
|
+
if (!doc)
|
|
131
|
+
return [];
|
|
132
|
+
const key = doc.sourceId ?? doc.id;
|
|
133
|
+
const rows = await db
|
|
134
|
+
.select()
|
|
135
|
+
.from(documents)
|
|
136
|
+
.where(or(eq(documents.id, key), eq(documents.sourceId, key)))
|
|
137
|
+
.orderBy(asc(documents.createdAt));
|
|
138
|
+
return rows.map((r) => ({
|
|
139
|
+
id: r.id,
|
|
140
|
+
locale: r.locale,
|
|
141
|
+
title: r.title,
|
|
142
|
+
status: statusOf(r),
|
|
143
|
+
sourceId: r.sourceId ?? null,
|
|
144
|
+
}));
|
|
145
|
+
},
|
|
146
|
+
/** Counts per collection and the latest changes across all of them: the panel's home. */
|
|
147
|
+
async overview(locale, recent = 8) {
|
|
148
|
+
const counts = await db
|
|
149
|
+
.select({
|
|
150
|
+
collection: documents.collection,
|
|
151
|
+
total: count(),
|
|
152
|
+
drafts: sql `sum(case when ${documents.publishedAt} is null then 1 else 0 end)`,
|
|
153
|
+
changed: sql `sum(case when ${documents.publishedAt} is not null and ${documents.hasChanges} then 1 else 0 end)`,
|
|
154
|
+
})
|
|
155
|
+
.from(documents)
|
|
156
|
+
.where(eq(documents.locale, locale))
|
|
157
|
+
.groupBy(documents.collection);
|
|
158
|
+
const rows = await db
|
|
159
|
+
.select()
|
|
160
|
+
.from(documents)
|
|
161
|
+
.where(eq(documents.locale, locale))
|
|
162
|
+
.orderBy(desc(documents.updatedAt), desc(sql `rowid`))
|
|
163
|
+
.limit(recent);
|
|
164
|
+
return {
|
|
165
|
+
counts: counts.map((c) => ({
|
|
166
|
+
collection: c.collection,
|
|
167
|
+
total: c.total,
|
|
168
|
+
drafts: Number(c.drafts ?? 0),
|
|
169
|
+
changed: Number(c.changed ?? 0),
|
|
170
|
+
})),
|
|
171
|
+
recent: rows.map((r) => ({
|
|
172
|
+
id: r.id,
|
|
173
|
+
collection: r.collection,
|
|
174
|
+
title: r.title,
|
|
175
|
+
slug: r.slug,
|
|
176
|
+
status: statusOf(r),
|
|
177
|
+
updatedAt: r.updatedAt,
|
|
178
|
+
publishedAt: r.publishedAt ?? null,
|
|
179
|
+
image: firstImage(r.data),
|
|
180
|
+
updatedBy: r.updatedBy ?? null,
|
|
181
|
+
})),
|
|
182
|
+
};
|
|
183
|
+
},
|
|
184
|
+
async get(id) {
|
|
185
|
+
const row = await db.query.documents.findFirst({ where: eq(documents.id, id) });
|
|
186
|
+
return row ? toDocument(row) : null;
|
|
187
|
+
},
|
|
188
|
+
async getMany(ids) {
|
|
189
|
+
if (!ids.length)
|
|
190
|
+
return [];
|
|
191
|
+
const rows = await db.select().from(documents).where(inArray(documents.id, ids));
|
|
192
|
+
return rows.map(toDocument);
|
|
193
|
+
},
|
|
194
|
+
/** Every published document of a collection (all locales unless one is given), oldest first. */
|
|
195
|
+
async published(collection, locale) {
|
|
196
|
+
const rows = await db
|
|
197
|
+
.select()
|
|
198
|
+
.from(documents)
|
|
199
|
+
.where(and(eq(documents.collection, collection), isNotNull(documents.publishedAt), locale ? eq(documents.locale, locale) : undefined))
|
|
200
|
+
.orderBy(asc(documents.createdAt), asc(sql `rowid`));
|
|
201
|
+
return rows.map(toDocument);
|
|
202
|
+
},
|
|
203
|
+
async getBySlug(collection, slug, locale) {
|
|
204
|
+
const row = await db.query.documents.findFirst({
|
|
205
|
+
where: and(eq(documents.collection, collection), eq(documents.slug, slug), eq(documents.locale, locale)),
|
|
206
|
+
});
|
|
207
|
+
return row ? toDocument(row) : null;
|
|
208
|
+
},
|
|
209
|
+
async create(input) {
|
|
210
|
+
const now = new Date();
|
|
211
|
+
const id = randomUUID();
|
|
212
|
+
await write(input.slug, () => db.insert(documents).values({
|
|
213
|
+
id,
|
|
214
|
+
collection: input.collection,
|
|
215
|
+
locale: input.locale,
|
|
216
|
+
slug: input.slug,
|
|
217
|
+
title: input.title,
|
|
218
|
+
data: input.data,
|
|
219
|
+
hasChanges: true,
|
|
220
|
+
createdAt: now,
|
|
221
|
+
updatedAt: now,
|
|
222
|
+
createdBy: input.userId,
|
|
223
|
+
updatedBy: input.userId,
|
|
224
|
+
sourceId: input.sourceId ?? null,
|
|
225
|
+
}));
|
|
226
|
+
await addRevision(id, 'save', input.data, input.userId);
|
|
227
|
+
return (await this.get(id));
|
|
228
|
+
},
|
|
229
|
+
async update(id, input) {
|
|
230
|
+
const result = await write(input.slug, () => db
|
|
231
|
+
.update(documents)
|
|
232
|
+
.set({
|
|
233
|
+
title: input.title,
|
|
234
|
+
slug: input.slug,
|
|
235
|
+
data: input.data,
|
|
236
|
+
hasChanges: true,
|
|
237
|
+
updatedAt: new Date(),
|
|
238
|
+
updatedBy: input.userId,
|
|
239
|
+
})
|
|
240
|
+
.where(eq(documents.id, id)));
|
|
241
|
+
if (result.rowsAffected === 0)
|
|
242
|
+
return null;
|
|
243
|
+
await addSaveRevision(id, input.data, input.userId);
|
|
244
|
+
return this.get(id);
|
|
245
|
+
},
|
|
246
|
+
/** Makes the current draft live; `data` is the validated form of the draft, when the caller has one. */
|
|
247
|
+
async publish(id, userId, data) {
|
|
248
|
+
const current = await this.get(id);
|
|
249
|
+
if (!current)
|
|
250
|
+
return null;
|
|
251
|
+
const live = data ?? current.data;
|
|
252
|
+
const now = new Date();
|
|
253
|
+
await db
|
|
254
|
+
.update(documents)
|
|
255
|
+
.set({ publishedData: live, publishedAt: now, hasChanges: false, updatedAt: now, updatedBy: userId })
|
|
256
|
+
.where(eq(documents.id, id));
|
|
257
|
+
await addRevision(id, 'publish', live, userId);
|
|
258
|
+
return this.get(id);
|
|
259
|
+
},
|
|
260
|
+
/** Takes the document off the live site; the draft stays. */
|
|
261
|
+
async unpublish(id, userId) {
|
|
262
|
+
const result = await db
|
|
263
|
+
.update(documents)
|
|
264
|
+
.set({ publishedData: null, publishedAt: null, hasChanges: true, updatedAt: new Date(), updatedBy: userId })
|
|
265
|
+
.where(eq(documents.id, id));
|
|
266
|
+
return result.rowsAffected ? this.get(id) : null;
|
|
267
|
+
},
|
|
268
|
+
async remove(id) {
|
|
269
|
+
const result = await db.delete(documents).where(eq(documents.id, id));
|
|
270
|
+
if (!result.rowsAffected)
|
|
271
|
+
return false;
|
|
272
|
+
// Translations of a deleted original stay together: the oldest becomes their original.
|
|
273
|
+
const orphans = await db
|
|
274
|
+
.select({ id: documents.id })
|
|
275
|
+
.from(documents)
|
|
276
|
+
.where(eq(documents.sourceId, id))
|
|
277
|
+
.orderBy(asc(documents.createdAt));
|
|
278
|
+
const [first, ...rest] = orphans;
|
|
279
|
+
if (first) {
|
|
280
|
+
await db.update(documents).set({ sourceId: null }).where(eq(documents.id, first.id));
|
|
281
|
+
if (rest.length)
|
|
282
|
+
await db
|
|
283
|
+
.update(documents)
|
|
284
|
+
.set({ sourceId: first.id })
|
|
285
|
+
.where(inArray(documents.id, rest.map((r) => r.id)));
|
|
286
|
+
}
|
|
287
|
+
return true;
|
|
288
|
+
},
|
|
289
|
+
async revisions(documentId) {
|
|
290
|
+
const rows = await db
|
|
291
|
+
.select()
|
|
292
|
+
.from(revisions)
|
|
293
|
+
.where(eq(revisions.documentId, documentId))
|
|
294
|
+
// rowid follows insertion order, so revisions written in the same millisecond stay ordered.
|
|
295
|
+
.orderBy(desc(sql `rowid`))
|
|
296
|
+
.limit(100);
|
|
297
|
+
return rows.map((r) => ({ id: r.id, kind: r.kind, data: r.data, createdAt: r.createdAt, authorId: r.authorId }));
|
|
298
|
+
},
|
|
299
|
+
/** Copies an old version back into the draft, recorded as a new revision. */
|
|
300
|
+
async restore(documentId, revisionId, userId) {
|
|
301
|
+
const revision = await db.query.revisions.findFirst({
|
|
302
|
+
where: and(eq(revisions.id, revisionId), eq(revisions.documentId, documentId)),
|
|
303
|
+
});
|
|
304
|
+
if (!revision)
|
|
305
|
+
return null;
|
|
306
|
+
await db
|
|
307
|
+
.update(documents)
|
|
308
|
+
.set({ data: revision.data, hasChanges: true, updatedAt: new Date(), updatedBy: userId })
|
|
309
|
+
.where(eq(documents.id, documentId));
|
|
310
|
+
await addRevision(documentId, 'restore', revision.data, userId);
|
|
311
|
+
return this.get(documentId);
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type Client } from '@libsql/client';
|
|
2
|
+
import { type LibSQLDatabase } from 'drizzle-orm/libsql';
|
|
3
|
+
import * as tables from './tables.ts';
|
|
4
|
+
export type Db = LibSQLDatabase<typeof tables>;
|
|
5
|
+
export interface Database {
|
|
6
|
+
db: Db;
|
|
7
|
+
client: Client;
|
|
8
|
+
/** Where it lives: `file:…`, `:memory:` or a hosted libSQL URL. */
|
|
9
|
+
url: string;
|
|
10
|
+
close: () => void;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Opens a database: `file:path.db` for local SQLite (the default), `:memory:` for tests,
|
|
14
|
+
* or a libSQL / Turso URL for hosted setups. Pending migrations run automatically.
|
|
15
|
+
*/
|
|
16
|
+
export declare function openDatabase(options: {
|
|
17
|
+
url: string;
|
|
18
|
+
authToken?: string;
|
|
19
|
+
}): Promise<Database>;
|
package/src/database.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { mkdir } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { createClient } from '@libsql/client';
|
|
4
|
+
import { drizzle } from 'drizzle-orm/libsql';
|
|
5
|
+
import * as tables from './tables.js';
|
|
6
|
+
import { MIGRATIONS } from './tables.js';
|
|
7
|
+
/**
|
|
8
|
+
* Opens a database: `file:path.db` for local SQLite (the default), `:memory:` for tests,
|
|
9
|
+
* or a libSQL / Turso URL for hosted setups. Pending migrations run automatically.
|
|
10
|
+
*/
|
|
11
|
+
export async function openDatabase(options) {
|
|
12
|
+
if (options.url.startsWith('file:')) {
|
|
13
|
+
await mkdir(dirname(options.url.slice('file:'.length)), { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
const client = createClient({ url: options.url, authToken: options.authToken });
|
|
16
|
+
await client.execute('PRAGMA foreign_keys = ON');
|
|
17
|
+
if (options.url.startsWith('file:'))
|
|
18
|
+
await client.execute('PRAGMA journal_mode = WAL');
|
|
19
|
+
await migrate(client);
|
|
20
|
+
return { db: drizzle(client, { schema: tables }), client, url: options.url, close: () => client.close() };
|
|
21
|
+
}
|
|
22
|
+
async function migrate(client) {
|
|
23
|
+
await client.execute('CREATE TABLE IF NOT EXISTS _materialize_schema (version INTEGER NOT NULL)');
|
|
24
|
+
const row = (await client.execute('SELECT version FROM _materialize_schema LIMIT 1')).rows[0];
|
|
25
|
+
const current = row ? Number(row.version) : 0;
|
|
26
|
+
for (let v = current; v < MIGRATIONS.length; v++) {
|
|
27
|
+
const steps = MIGRATIONS[v] ?? [];
|
|
28
|
+
await client.batch([
|
|
29
|
+
...steps,
|
|
30
|
+
current === 0 && v === 0
|
|
31
|
+
? { sql: 'INSERT INTO _materialize_schema (version) VALUES (?)', args: [v + 1] }
|
|
32
|
+
: { sql: 'UPDATE _materialize_schema SET version = ?', args: [v + 1] },
|
|
33
|
+
], 'write');
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { type ActivityEntry, type ActivityStore, createActivityStore } from './activity.ts';
|
|
2
|
+
export { type ApiToken, type AuthStore, createAuthStore, type DeviceSession, EmailTakenError, type Invite, type Member, type Role, TOKEN_PREFIX, type User, } from './auth.ts';
|
|
3
|
+
export { type Comment, type CommentStore, createCommentStore } from './comments.ts';
|
|
4
|
+
export { type ContentStore, createContentStore, type DocumentStatus, type DocumentSummary, type ListOptions, type Revision, SlugTakenError, type StoredDocument, type Translation, } from './content.ts';
|
|
5
|
+
export { type Database, type Db, openDatabase } from './database.ts';
|
|
6
|
+
export { createMediaStore, type MediaAsset, type MediaStore } from './media.ts';
|
|
7
|
+
export { createSettingsStore, type SettingsStore } from './settings.ts';
|
|
8
|
+
export { createSubmissionStore, type Submission, type SubmissionStore } from './submissions.ts';
|
package/src/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { createActivityStore } from './activity.js';
|
|
2
|
+
export { createAuthStore, EmailTakenError, TOKEN_PREFIX, } from './auth.js';
|
|
3
|
+
export { createCommentStore } from './comments.js';
|
|
4
|
+
export { createContentStore, SlugTakenError, } from './content.js';
|
|
5
|
+
export { openDatabase } from './database.js';
|
|
6
|
+
export { createMediaStore } from './media.js';
|
|
7
|
+
export { createSettingsStore } from './settings.js';
|
|
8
|
+
export { createSubmissionStore } from './submissions.js';
|
package/src/media.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Db } from './database.ts';
|
|
2
|
+
import { media } from './tables.ts';
|
|
3
|
+
export type MediaAsset = typeof media.$inferSelect;
|
|
4
|
+
export declare function createMediaStore(db: Db): {
|
|
5
|
+
create(input: Omit<MediaAsset, 'id' | 'createdAt' | 'alt'> & {
|
|
6
|
+
alt?: string;
|
|
7
|
+
}): Promise<MediaAsset>;
|
|
8
|
+
list(options?: {
|
|
9
|
+
search?: string;
|
|
10
|
+
limit?: number;
|
|
11
|
+
offset?: number;
|
|
12
|
+
}): Promise<{
|
|
13
|
+
items: {
|
|
14
|
+
id: string;
|
|
15
|
+
key: string;
|
|
16
|
+
filename: string;
|
|
17
|
+
mime: string;
|
|
18
|
+
size: number;
|
|
19
|
+
width: number | null;
|
|
20
|
+
height: number | null;
|
|
21
|
+
alt: string;
|
|
22
|
+
createdAt: Date;
|
|
23
|
+
createdBy: string | null;
|
|
24
|
+
}[];
|
|
25
|
+
total: number;
|
|
26
|
+
}>;
|
|
27
|
+
get(id: string): Promise<MediaAsset | null>;
|
|
28
|
+
getByKey(key: string): Promise<MediaAsset | null>;
|
|
29
|
+
update(id: string, input: {
|
|
30
|
+
alt: string;
|
|
31
|
+
}): Promise<MediaAsset | null>;
|
|
32
|
+
remove(id: string): Promise<boolean>;
|
|
33
|
+
/** How many documents mention the file, in their draft or published version. */
|
|
34
|
+
usage(key: string): Promise<number>;
|
|
35
|
+
};
|
|
36
|
+
export type MediaStore = ReturnType<typeof createMediaStore>;
|
package/src/media.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { count, desc, eq, like, or, sql } from 'drizzle-orm';
|
|
3
|
+
import { documents, media } from './tables.js';
|
|
4
|
+
export function createMediaStore(db) {
|
|
5
|
+
return {
|
|
6
|
+
async create(input) {
|
|
7
|
+
const row = { id: randomUUID(), createdAt: new Date(), alt: '', ...input };
|
|
8
|
+
await db.insert(media).values(row);
|
|
9
|
+
return row;
|
|
10
|
+
},
|
|
11
|
+
async list(options = {}) {
|
|
12
|
+
const term = options.search?.replace(/[%_]/g, '');
|
|
13
|
+
const where = term ? or(like(media.filename, `%${term}%`), like(media.alt, `%${term}%`)) : undefined;
|
|
14
|
+
const items = await db
|
|
15
|
+
.select()
|
|
16
|
+
.from(media)
|
|
17
|
+
.where(where)
|
|
18
|
+
.orderBy(desc(media.createdAt), desc(sql `rowid`))
|
|
19
|
+
.limit(Math.min(options.limit ?? 60, 200))
|
|
20
|
+
.offset(options.offset ?? 0);
|
|
21
|
+
const [total] = await db.select({ n: count() }).from(media).where(where);
|
|
22
|
+
return { items, total: total?.n ?? 0 };
|
|
23
|
+
},
|
|
24
|
+
async get(id) {
|
|
25
|
+
return (await db.query.media.findFirst({ where: eq(media.id, id) })) ?? null;
|
|
26
|
+
},
|
|
27
|
+
async getByKey(key) {
|
|
28
|
+
return (await db.query.media.findFirst({ where: eq(media.key, key) })) ?? null;
|
|
29
|
+
},
|
|
30
|
+
async update(id, input) {
|
|
31
|
+
await db.update(media).set({ alt: input.alt }).where(eq(media.id, id));
|
|
32
|
+
return this.get(id);
|
|
33
|
+
},
|
|
34
|
+
async remove(id) {
|
|
35
|
+
return (await db.delete(media).where(eq(media.id, id))).rowsAffected > 0;
|
|
36
|
+
},
|
|
37
|
+
/** How many documents mention the file, in their draft or published version. */
|
|
38
|
+
async usage(key) {
|
|
39
|
+
const needle = `%/media/${key.replace(/[%_]/g, '')}%`;
|
|
40
|
+
const [row] = await db
|
|
41
|
+
.select({ n: count() })
|
|
42
|
+
.from(documents)
|
|
43
|
+
.where(or(like(documents.data, needle), like(documents.publishedData, needle)));
|
|
44
|
+
return row?.n ?? 0;
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Db } from './database.ts';
|
|
2
|
+
/** Site-wide values by key (the design theme, for one), saved as JSON. */
|
|
3
|
+
export declare function createSettingsStore(db: Db): {
|
|
4
|
+
get<T = unknown>(key: string): Promise<T | null>;
|
|
5
|
+
set(key: string, value: unknown, userId?: string): Promise<void>;
|
|
6
|
+
};
|
|
7
|
+
export type SettingsStore = ReturnType<typeof createSettingsStore>;
|
package/src/settings.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { eq } from 'drizzle-orm';
|
|
2
|
+
import { settings } from './tables.js';
|
|
3
|
+
/** Site-wide values by key (the design theme, for one), saved as JSON. */
|
|
4
|
+
export function createSettingsStore(db) {
|
|
5
|
+
return {
|
|
6
|
+
async get(key) {
|
|
7
|
+
const row = await db.query.settings.findFirst({ where: eq(settings.key, key) });
|
|
8
|
+
return row?.value ?? null;
|
|
9
|
+
},
|
|
10
|
+
async set(key, value, userId) {
|
|
11
|
+
const row = { key, value, updatedAt: new Date(), updatedBy: userId ?? null };
|
|
12
|
+
await db
|
|
13
|
+
.insert(settings)
|
|
14
|
+
.values(row)
|
|
15
|
+
.onConflictDoUpdate({
|
|
16
|
+
target: settings.key,
|
|
17
|
+
set: { value, updatedAt: row.updatedAt, updatedBy: row.updatedBy },
|
|
18
|
+
});
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Db } from './database.ts';
|
|
2
|
+
export interface Submission {
|
|
3
|
+
id: string;
|
|
4
|
+
form: string;
|
|
5
|
+
page: string;
|
|
6
|
+
data: Record<string, string>;
|
|
7
|
+
read: boolean;
|
|
8
|
+
mailed: boolean;
|
|
9
|
+
createdAt: Date;
|
|
10
|
+
}
|
|
11
|
+
/** Messages from the site's forms: kept even when email is not set up, so nothing is lost. */
|
|
12
|
+
export declare function createSubmissionStore(db: Db): {
|
|
13
|
+
add(input: {
|
|
14
|
+
form: string;
|
|
15
|
+
page: string;
|
|
16
|
+
data: Record<string, string>;
|
|
17
|
+
}): Promise<Submission>;
|
|
18
|
+
list(limit?: number, offset?: number): Promise<{
|
|
19
|
+
items: Submission[];
|
|
20
|
+
total: number;
|
|
21
|
+
unread: number;
|
|
22
|
+
}>;
|
|
23
|
+
markMailed(id: string): Promise<void>;
|
|
24
|
+
markRead(id: string, read?: boolean): Promise<void>;
|
|
25
|
+
remove(id: string): Promise<void>;
|
|
26
|
+
};
|
|
27
|
+
export type SubmissionStore = ReturnType<typeof createSubmissionStore>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { count, desc, eq } from 'drizzle-orm';
|
|
3
|
+
import { submissions } from './tables.js';
|
|
4
|
+
/** Messages from the site's forms: kept even when email is not set up, so nothing is lost. */
|
|
5
|
+
export function createSubmissionStore(db) {
|
|
6
|
+
return {
|
|
7
|
+
async add(input) {
|
|
8
|
+
const row = { id: randomUUID(), ...input, read: false, mailed: false, createdAt: new Date() };
|
|
9
|
+
await db.insert(submissions).values(row);
|
|
10
|
+
return row;
|
|
11
|
+
},
|
|
12
|
+
async list(limit = 50, offset = 0) {
|
|
13
|
+
const items = await db.select().from(submissions).orderBy(desc(submissions.createdAt)).limit(limit).offset(offset);
|
|
14
|
+
const [all] = await db.select({ n: count() }).from(submissions);
|
|
15
|
+
const [unread] = await db.select({ n: count() }).from(submissions).where(eq(submissions.read, false));
|
|
16
|
+
return { items, total: all?.n ?? 0, unread: unread?.n ?? 0 };
|
|
17
|
+
},
|
|
18
|
+
async markMailed(id) {
|
|
19
|
+
await db.update(submissions).set({ mailed: true }).where(eq(submissions.id, id));
|
|
20
|
+
},
|
|
21
|
+
async markRead(id, read = true) {
|
|
22
|
+
await db.update(submissions).set({ read }).where(eq(submissions.id, id));
|
|
23
|
+
},
|
|
24
|
+
async remove(id) {
|
|
25
|
+
await db.delete(submissions).where(eq(submissions.id, id));
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|