@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/src/tables.js ADDED
@@ -0,0 +1,316 @@
1
+ import { index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
2
+ /**
3
+ * Content is stored as validated JSON: the schema lives in code, so changing fields never needs a migration.
4
+ * `data` is the working draft; `publishedData` is what the live site serves.
5
+ */
6
+ export const documents = sqliteTable('documents', {
7
+ id: text('id').primaryKey(),
8
+ collection: text('collection').notNull(),
9
+ locale: text('locale').notNull(),
10
+ slug: text('slug'),
11
+ title: text('title').notNull(),
12
+ data: text('data', { mode: 'json' }).$type().notNull(),
13
+ publishedData: text('published_data', { mode: 'json' }).$type(),
14
+ hasChanges: integer('has_changes', { mode: 'boolean' }).notNull().default(true),
15
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
16
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
17
+ publishedAt: integer('published_at', { mode: 'timestamp_ms' }),
18
+ createdBy: text('created_by'),
19
+ updatedBy: text('updated_by'),
20
+ /** The document this one translates (in the site's main language); null for originals. */
21
+ sourceId: text('source_id'),
22
+ }, (t) => [
23
+ uniqueIndex('documents_slug_unique').on(t.collection, t.locale, t.slug),
24
+ index('documents_list').on(t.collection, t.locale, t.updatedAt),
25
+ index('documents_source').on(t.sourceId),
26
+ ]);
27
+ export const revisions = sqliteTable('revisions', {
28
+ id: text('id').primaryKey(),
29
+ documentId: text('document_id')
30
+ .notNull()
31
+ .references(() => documents.id, { onDelete: 'cascade' }),
32
+ kind: text('kind', { enum: ['save', 'publish', 'restore'] }).notNull(),
33
+ data: text('data', { mode: 'json' }).$type().notNull(),
34
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
35
+ authorId: text('author_id'),
36
+ }, (t) => [index('revisions_document').on(t.documentId, t.createdAt)]);
37
+ export const users = sqliteTable('users', {
38
+ id: text('id').primaryKey(),
39
+ email: text('email').notNull().unique(),
40
+ name: text('name').notNull(),
41
+ role: text('role', { enum: ['admin', 'editor'] }).notNull(),
42
+ passwordHash: text('password_hash').notNull(),
43
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
44
+ lastSeenAt: integer('last_seen_at', { mode: 'timestamp_ms' }),
45
+ /** Two-step verification: the authenticator app's secret, once confirmed. */
46
+ totpSecret: text('totp_secret'),
47
+ /** A secret shown during setup, waiting for its first code. */
48
+ totpPending: text('totp_pending'),
49
+ /** The last time step accepted: a code works once. */
50
+ totpLastStep: integer('totp_last_step'),
51
+ /** Hashes of the unused recovery codes. */
52
+ recoveryCodes: text('recovery_codes', { mode: 'json' }).$type(),
53
+ });
54
+ /** Only a SHA-256 hash of each session token is stored. */
55
+ export const sessions = sqliteTable('sessions', {
56
+ id: text('id').primaryKey(),
57
+ userId: text('user_id')
58
+ .notNull()
59
+ .references(() => users.id, { onDelete: 'cascade' }),
60
+ expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
61
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
62
+ /** Last request: a session left unused for too long ends. */
63
+ lastSeenAt: integer('last_seen_at', { mode: 'timestamp_ms' }),
64
+ /** Which device it is, for the list of connected devices. */
65
+ userAgent: text('user_agent'),
66
+ ip: text('ip'),
67
+ });
68
+ /** Failed sign-ins, by email and by address: repeated failures lock that key for a while. */
69
+ export const loginAttempts = sqliteTable('login_attempts', {
70
+ key: text('key').primaryKey(),
71
+ failures: integer('failures').notNull(),
72
+ lockedUntil: integer('locked_until', { mode: 'timestamp_ms' }),
73
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
74
+ });
75
+ /**
76
+ * One-time links: an invitation to join, or a password reset for an existing user.
77
+ * Like sessions, only a hash of the token is stored.
78
+ */
79
+ export const invites = sqliteTable('invites', {
80
+ id: text('id').primaryKey(),
81
+ kind: text('kind', { enum: ['invite', 'reset'] }).notNull(),
82
+ email: text('email').notNull(),
83
+ name: text('name').notNull().default(''),
84
+ role: text('role', { enum: ['admin', 'editor'] }).notNull(),
85
+ userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }),
86
+ createdBy: text('created_by'),
87
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
88
+ expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
89
+ });
90
+ /** Site-wide settings edited in the panel, such as the design theme. One JSON value per key. */
91
+ export const settings = sqliteTable('settings', {
92
+ key: text('key').primaryKey(),
93
+ value: text('value', { mode: 'json' }).$type().notNull(),
94
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
95
+ updatedBy: text('updated_by'),
96
+ });
97
+ /** Uploaded files. The file lives in storage under `key`; documents reference it as `/media/<key>`. */
98
+ export const media = sqliteTable('media', {
99
+ id: text('id').primaryKey(),
100
+ key: text('key').notNull().unique(),
101
+ filename: text('filename').notNull(),
102
+ mime: text('mime').notNull(),
103
+ size: integer('size').notNull(),
104
+ width: integer('width'),
105
+ height: integer('height'),
106
+ alt: text('alt').notNull().default(''),
107
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
108
+ createdBy: text('created_by'),
109
+ }, (t) => [index('media_created').on(t.createdAt)]);
110
+ /** Messages sent from forms on the site, with what was filled in. */
111
+ export const submissions = sqliteTable('submissions', {
112
+ id: text('id').primaryKey(),
113
+ form: text('form').notNull(),
114
+ page: text('page').notNull().default(''),
115
+ data: text('data', { mode: 'json' }).$type().notNull(),
116
+ read: integer('read', { mode: 'boolean' }).notNull().default(false),
117
+ mailed: integer('mailed', { mode: 'boolean' }).notNull().default(false),
118
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
119
+ }, (t) => [index('submissions_created').on(t.createdAt)]);
120
+ /** Notes left on a page in the editor, pinned to an element or to the whole page. */
121
+ export const comments = sqliteTable('comments', {
122
+ id: text('id').primaryKey(),
123
+ documentId: text('document_id').notNull(),
124
+ nodeId: text('node_id'),
125
+ authorId: text('author_id'),
126
+ authorName: text('author_name').notNull().default(''),
127
+ body: text('body').notNull(),
128
+ resolved: integer('resolved', { mode: 'boolean' }).notNull().default(false),
129
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
130
+ }, (t) => [index('comments_document').on(t.documentId)]);
131
+ /**
132
+ * Personal access tokens, for programs acting as a person (an MCP client, a script). Like sessions,
133
+ * only a hash is stored; `hint` keeps the last characters so people can tell them apart.
134
+ */
135
+ export const apiTokens = sqliteTable('api_tokens', {
136
+ id: text('id').primaryKey(),
137
+ userId: text('user_id')
138
+ .notNull()
139
+ .references(() => users.id, { onDelete: 'cascade' }),
140
+ name: text('name').notNull(),
141
+ tokenHash: text('token_hash').notNull().unique(),
142
+ hint: text('hint').notNull(),
143
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
144
+ lastUsedAt: integer('last_used_at', { mode: 'timestamp_ms' }),
145
+ }, (t) => [index('api_tokens_user').on(t.userId)]);
146
+ /**
147
+ * What happened in the panel, for Impostazioni → Sicurezza: sign-ins, people, publishing,
148
+ * settings. The name is kept as it was, so entries stay readable after someone leaves.
149
+ */
150
+ export const activity = sqliteTable('activity', {
151
+ id: text('id').primaryKey(),
152
+ at: integer('at', { mode: 'timestamp_ms' }).notNull(),
153
+ userId: text('user_id'),
154
+ userName: text('user_name').notNull().default(''),
155
+ action: text('action').notNull(),
156
+ /** What it was done to: a page's title, a person's email… */
157
+ target: text('target').notNull().default(''),
158
+ detail: text('detail', { mode: 'json' }).$type(),
159
+ ip: text('ip'),
160
+ }, (t) => [index('activity_at').on(t.at), index('activity_action').on(t.action, t.at)]);
161
+ /** Plain DDL applied on startup; versioned so later releases can add steps. */
162
+ export const MIGRATIONS = [
163
+ [
164
+ `CREATE TABLE IF NOT EXISTS documents (
165
+ id TEXT PRIMARY KEY,
166
+ collection TEXT NOT NULL,
167
+ locale TEXT NOT NULL,
168
+ slug TEXT,
169
+ title TEXT NOT NULL,
170
+ data TEXT NOT NULL,
171
+ published_data TEXT,
172
+ has_changes INTEGER NOT NULL DEFAULT 1,
173
+ created_at INTEGER NOT NULL,
174
+ updated_at INTEGER NOT NULL,
175
+ published_at INTEGER,
176
+ created_by TEXT,
177
+ updated_by TEXT
178
+ )`,
179
+ 'CREATE UNIQUE INDEX IF NOT EXISTS documents_slug_unique ON documents (collection, locale, slug)',
180
+ 'CREATE INDEX IF NOT EXISTS documents_list ON documents (collection, locale, updated_at)',
181
+ `CREATE TABLE IF NOT EXISTS revisions (
182
+ id TEXT PRIMARY KEY,
183
+ document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
184
+ kind TEXT NOT NULL,
185
+ data TEXT NOT NULL,
186
+ created_at INTEGER NOT NULL,
187
+ author_id TEXT
188
+ )`,
189
+ 'CREATE INDEX IF NOT EXISTS revisions_document ON revisions (document_id, created_at)',
190
+ `CREATE TABLE IF NOT EXISTS users (
191
+ id TEXT PRIMARY KEY,
192
+ email TEXT NOT NULL UNIQUE,
193
+ name TEXT NOT NULL,
194
+ role TEXT NOT NULL,
195
+ password_hash TEXT NOT NULL,
196
+ created_at INTEGER NOT NULL
197
+ )`,
198
+ `CREATE TABLE IF NOT EXISTS sessions (
199
+ id TEXT PRIMARY KEY,
200
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
201
+ expires_at INTEGER NOT NULL,
202
+ created_at INTEGER NOT NULL
203
+ )`,
204
+ ],
205
+ [
206
+ `CREATE TABLE IF NOT EXISTS media (
207
+ id TEXT PRIMARY KEY,
208
+ key TEXT NOT NULL UNIQUE,
209
+ filename TEXT NOT NULL,
210
+ mime TEXT NOT NULL,
211
+ size INTEGER NOT NULL,
212
+ width INTEGER,
213
+ height INTEGER,
214
+ alt TEXT NOT NULL DEFAULT '',
215
+ created_at INTEGER NOT NULL,
216
+ created_by TEXT
217
+ )`,
218
+ 'CREATE INDEX IF NOT EXISTS media_created ON media (created_at)',
219
+ ],
220
+ [
221
+ `CREATE TABLE IF NOT EXISTS invites (
222
+ id TEXT PRIMARY KEY,
223
+ kind TEXT NOT NULL,
224
+ email TEXT NOT NULL,
225
+ name TEXT NOT NULL DEFAULT '',
226
+ role TEXT NOT NULL,
227
+ user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
228
+ created_by TEXT,
229
+ created_at INTEGER NOT NULL,
230
+ expires_at INTEGER NOT NULL
231
+ )`,
232
+ ],
233
+ [
234
+ 'ALTER TABLE users ADD COLUMN last_seen_at INTEGER',
235
+ // Before this column, sign-ins were only visible through the sessions still open.
236
+ 'UPDATE users SET last_seen_at = (SELECT MAX(created_at) FROM sessions WHERE sessions.user_id = users.id)',
237
+ ],
238
+ [
239
+ `CREATE TABLE IF NOT EXISTS settings (
240
+ key TEXT PRIMARY KEY,
241
+ value TEXT NOT NULL,
242
+ updated_at INTEGER NOT NULL,
243
+ updated_by TEXT
244
+ )`,
245
+ ],
246
+ [
247
+ `CREATE TABLE IF NOT EXISTS submissions (
248
+ id TEXT PRIMARY KEY,
249
+ form TEXT NOT NULL,
250
+ page TEXT NOT NULL DEFAULT '',
251
+ data TEXT NOT NULL,
252
+ read INTEGER NOT NULL DEFAULT 0,
253
+ mailed INTEGER NOT NULL DEFAULT 0,
254
+ created_at INTEGER NOT NULL
255
+ )`,
256
+ 'CREATE INDEX IF NOT EXISTS submissions_created ON submissions (created_at)',
257
+ ],
258
+ [
259
+ `CREATE TABLE IF NOT EXISTS comments (
260
+ id TEXT PRIMARY KEY,
261
+ document_id TEXT NOT NULL,
262
+ node_id TEXT,
263
+ author_id TEXT,
264
+ author_name TEXT NOT NULL DEFAULT '',
265
+ body TEXT NOT NULL,
266
+ resolved INTEGER NOT NULL DEFAULT 0,
267
+ created_at INTEGER NOT NULL
268
+ )`,
269
+ 'CREATE INDEX IF NOT EXISTS comments_document ON comments (document_id)',
270
+ ],
271
+ [
272
+ `CREATE TABLE IF NOT EXISTS api_tokens (
273
+ id TEXT PRIMARY KEY,
274
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
275
+ name TEXT NOT NULL,
276
+ token_hash TEXT NOT NULL UNIQUE,
277
+ hint TEXT NOT NULL,
278
+ created_at INTEGER NOT NULL,
279
+ last_used_at INTEGER
280
+ )`,
281
+ 'CREATE INDEX IF NOT EXISTS api_tokens_user ON api_tokens (user_id)',
282
+ ],
283
+ [
284
+ 'ALTER TABLE documents ADD COLUMN source_id TEXT',
285
+ 'CREATE INDEX IF NOT EXISTS documents_source ON documents (source_id)',
286
+ ],
287
+ [
288
+ 'ALTER TABLE users ADD COLUMN totp_secret TEXT',
289
+ 'ALTER TABLE users ADD COLUMN totp_pending TEXT',
290
+ 'ALTER TABLE users ADD COLUMN totp_last_step INTEGER',
291
+ 'ALTER TABLE users ADD COLUMN recovery_codes TEXT',
292
+ 'ALTER TABLE sessions ADD COLUMN last_seen_at INTEGER',
293
+ 'ALTER TABLE sessions ADD COLUMN user_agent TEXT',
294
+ 'ALTER TABLE sessions ADD COLUMN ip TEXT',
295
+ `CREATE TABLE IF NOT EXISTS login_attempts (
296
+ key TEXT PRIMARY KEY,
297
+ failures INTEGER NOT NULL,
298
+ locked_until INTEGER,
299
+ updated_at INTEGER NOT NULL
300
+ )`,
301
+ ],
302
+ [
303
+ `CREATE TABLE IF NOT EXISTS activity (
304
+ id TEXT PRIMARY KEY,
305
+ at INTEGER NOT NULL,
306
+ user_id TEXT,
307
+ user_name TEXT NOT NULL DEFAULT '',
308
+ action TEXT NOT NULL,
309
+ target TEXT NOT NULL DEFAULT '',
310
+ detail TEXT,
311
+ ip TEXT
312
+ )`,
313
+ 'CREATE INDEX IF NOT EXISTS activity_at ON activity (at)',
314
+ 'CREATE INDEX IF NOT EXISTS activity_action ON activity (action, at)',
315
+ ],
316
+ ];