@makerclay/core 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/LICENSE +221 -0
  2. package/README.md +82 -0
  3. package/package.json +52 -0
  4. package/src/admin/listing.js +149 -0
  5. package/src/admin/routes.js +362 -0
  6. package/src/attic.js +168 -0
  7. package/src/auth/can.js +79 -0
  8. package/src/auth/csrf.js +74 -0
  9. package/src/auth/none.js +24 -0
  10. package/src/auth/password.js +281 -0
  11. package/src/auth/passwords.js +79 -0
  12. package/src/auth/rate-limit.js +66 -0
  13. package/src/auth/sessions.js +86 -0
  14. package/src/auth/token-lanes.js +72 -0
  15. package/src/boot.js +120 -0
  16. package/src/client.js +99 -0
  17. package/src/collections/index.js +397 -0
  18. package/src/collections/routes.js +166 -0
  19. package/src/create-host.js +351 -0
  20. package/src/derived/data-extractor.js +22 -0
  21. package/src/derived/index.js +121 -0
  22. package/src/documents/format-html.js +313 -0
  23. package/src/documents/replace.js +257 -0
  24. package/src/documents/root-attrs.js +171 -0
  25. package/src/documents/serve.js +137 -0
  26. package/src/documents/stale.js +20 -0
  27. package/src/index.js +12 -0
  28. package/src/inspect.js +136 -0
  29. package/src/json-errors.js +59 -0
  30. package/src/livesync.js +75 -0
  31. package/src/nodes/identity.js +14 -0
  32. package/src/nodes/names.js +57 -0
  33. package/src/nodes/ops.js +613 -0
  34. package/src/nodes/scanner.js +321 -0
  35. package/src/nodes/store.js +111 -0
  36. package/src/pages.js +166 -0
  37. package/src/paths.js +302 -0
  38. package/src/recovery/overlay.js +327 -0
  39. package/src/recovery/replay.js +236 -0
  40. package/src/recovery-ui.js +185 -0
  41. package/src/recovery.js +30 -0
  42. package/src/requests.js +73 -0
  43. package/src/routes/meta.js +62 -0
  44. package/src/routes/read.js +105 -0
  45. package/src/routes/save.js +102 -0
  46. package/src/routes/sync.js +118 -0
  47. package/src/routes/upload.js +128 -0
  48. package/src/share/index.js +207 -0
  49. package/src/share/save-tokens.js +65 -0
  50. package/src/spec/codes.js +42 -0
  51. package/src/spec/meta.js +52 -0
  52. package/src/spec/wire.js +115 -0
  53. package/src/store/index.js +29 -0
  54. package/src/store/migrations/001-init.sql +114 -0
  55. package/src/store/sqlite.js +540 -0
  56. package/src/templates.js +50 -0
  57. package/src/tenants/index.js +355 -0
  58. package/src/tenants/isolation.js +91 -0
  59. package/src/tenants/routes.js +131 -0
  60. package/src/ui.js +95 -0
  61. package/src/util/cookies.js +26 -0
  62. package/src/util/express.js +8 -0
  63. package/src/util/fsx.js +205 -0
  64. package/src/util/id.js +37 -0
  65. package/src/util/lockfile.js +52 -0
  66. package/src/util/locks.js +35 -0
  67. package/src/util/multipart.js +33 -0
  68. package/src/versions/files.js +307 -0
  69. package/src/versions/index.js +16 -0
  70. package/src/versions/naming.js +172 -0
  71. package/src/versions/routes.js +91 -0
  72. package/src/wire-compat.js +61 -0
  73. package/ui/app.css +164 -0
  74. package/ui/attic.html +198 -0
  75. package/ui/dashboard.html +456 -0
  76. package/ui/editor.html +156 -0
  77. package/ui/error.html +18 -0
  78. package/ui/login.html +58 -0
  79. package/ui/records.html +173 -0
  80. package/ui/recovery.html +152 -0
  81. package/ui/setup.html +61 -0
  82. package/ui/share-qr.html +44 -0
  83. package/ui/templates/blank.html +16 -0
  84. package/ui/templates/devlog.html +71 -0
  85. package/ui/templates/hackable-dashboard.html +145 -0
  86. package/ui/templates/kanban.html +85 -0
  87. package/ui/templates/landing.html +108 -0
  88. package/ui/templates/writer.html +50 -0
  89. package/ui/tenants.html +172 -0
  90. package/ui/trash.html +134 -0
  91. package/ui/versions.html +114 -0
@@ -0,0 +1,65 @@
1
+ import { token as mintToken } from '../util/id.js';
2
+
3
+ // Spec §9 save tokens. One mechanism, two entry points: an isolated document
4
+ // served to a principal who may write it, and a share link with an edit grant.
5
+ // Carrying the credential in the path is what makes the same token work for
6
+ // fetch() and EventSource alike.
7
+
8
+ const DEFAULT_TTL_MS = 12 * 60 * 60 * 1000;
9
+
10
+ export function createSaveTokens({ store, nodes, clock, ids = { token: mintToken }, ttlMs = DEFAULT_TTL_MS }) {
11
+ function principalOf(actor) {
12
+ if (actor?.kind === 'owner') return 'owner';
13
+ if (actor?.kind === 'tenant') return `tenant:${actor.username}`;
14
+ return null;
15
+ }
16
+
17
+ async function mintFor({ actor, node, shareHash = null, grantKind = 'edit' }) {
18
+ if (!store || !node?.id) return null;
19
+ const now = clock.now();
20
+ const principal = shareHash ? null : principalOf(actor);
21
+ if (!principal && !shareHash) return null;
22
+
23
+ // A token must have a row to cascade from, so minting one materializes the
24
+ // node. Publishing a version does NOT: nothing under versions/ touches the
25
+ // store. So the two events that create a row for a file nobody has edited
26
+ // are a save and a token, and under the default `untrusted` isolation the
27
+ // token is minted on a plain GET by anyone who may write the document. One
28
+ // browser visit to an rsynced file is enough to give it a row.
29
+ const row = nodes.materialize(node);
30
+
31
+ const existing = store.findSaveToken({ nodeId: row.id, principal, shareHash, now });
32
+ if (existing) return existing.token;
33
+
34
+ const value = ids.token();
35
+ store.insertSaveToken({
36
+ token: value,
37
+ nodeId: row.id,
38
+ principal,
39
+ shareHash,
40
+ grantKind,
41
+ expiresAt: now + ttlMs,
42
+ });
43
+ return value;
44
+ }
45
+
46
+ function revokeForNode(nodeId) {
47
+ if (!store) return 0;
48
+ return store.deleteSaveTokensForNode(nodeId);
49
+ }
50
+
51
+ // A save token is a session by another name: it is the only write lane a
52
+ // sandboxed document has, because an opaque origin cannot send a cookie. So
53
+ // anything that ends a principal's sessions has to end their tokens too.
54
+ function revokeForPrincipal(principal) {
55
+ if (!store || !principal) return 0;
56
+ return store.deleteSaveTokensForPrincipal(principal);
57
+ }
58
+
59
+ function purgeExpired() {
60
+ if (!store) return 0;
61
+ return store.purgeExpiredSaveTokens(clock.now());
62
+ }
63
+
64
+ return { mintFor, revokeForNode, revokeForPrincipal, purgeExpired };
65
+ }
@@ -0,0 +1,42 @@
1
+ // The spec §3 error-code registry, plus the small set of host-scope codes
2
+ // makerclay adds under the same open-registry rule. Every JSON error the host
3
+ // emits names one of these, so a client can branch on `code` instead of
4
+ // pattern-matching `msg`.
5
+
6
+ export const CODES = {
7
+ unauthorized: 401,
8
+ forbidden: 403,
9
+ 'not-found': 404,
10
+ 'too-large': 413,
11
+ 'invalid-document': 422,
12
+ conflict: 412,
13
+ 'read-only': 403,
14
+ 'unsupported-type': 415,
15
+ 'payment-required': 402,
16
+
17
+ // makerclay additions, all host-scope.
18
+ 'bad-request': 400,
19
+ 'unsupported-media-type': 415,
20
+ 'rate-limited': 429,
21
+ reserved: 410,
22
+ 'server-error': 500,
23
+ unavailable: 503,
24
+ };
25
+
26
+ export function statusForCode(code) {
27
+ return CODES[code] || 500;
28
+ }
29
+
30
+ export class HostError extends Error {
31
+ constructor(code, message, status = null) {
32
+ super(message);
33
+ this.name = 'HostError';
34
+ this.code = code;
35
+ this.status = status || statusForCode(code);
36
+ }
37
+ }
38
+
39
+ export const notFound = (msg = 'Not found') => new HostError('not-found', msg);
40
+ export const forbidden = (msg = 'Forbidden') => new HostError('forbidden', msg);
41
+ export const unauthorized = (msg = 'Unauthorized') => new HostError('unauthorized', msg);
42
+ export const conflict = (msg = 'Conflict') => new HostError('conflict', msg);
@@ -0,0 +1,52 @@
1
+ import { can } from '../auth/can.js';
2
+
3
+ // GET /_/meta (spec §5). Two scopes in one answer:
4
+ //
5
+ // `spec` and `extensions` describe the HOST. They are the same for every
6
+ // document it serves and nothing in them belongs to any one person, so they
7
+ // are never gated and answer CORS-open. Gate discovery tighter than the
8
+ // documents themselves and a host strands its own clients.
9
+ //
10
+ // `document` describes the document named by Document-URL, and it is the only
11
+ // part ever withheld. Withholding is by OMISSION: for a caller who may not see
12
+ // the document the answer is byte-identical to the answer for a document that
13
+ // does not exist, so /_/meta can never be used to probe.
14
+
15
+ export const SPEC_VERSION = 1;
16
+
17
+ function extensionList(capabilities = {}) {
18
+ const names = [];
19
+ if (capabilities.conditional !== false) names.push('conditional');
20
+ if (capabilities.sync !== false) names.push('sync');
21
+ if (capabilities.format !== false) names.push('format');
22
+ if (capabilities.upload !== false) names.push('upload');
23
+ return names;
24
+ }
25
+
26
+ export function hostMeta(capabilities) {
27
+ return { spec: SPEC_VERSION, extensions: extensionList(capabilities) };
28
+ }
29
+
30
+ export function documentBlock({ node, etag, actor, ctx, limits, isolated, capabilities = {} }) {
31
+ const writable = can(actor, 'write', node, ctx);
32
+ const block = {
33
+ etag,
34
+ writable,
35
+ versions: true,
36
+ signups: !!node.signups,
37
+ isolated: !!isolated,
38
+ maxBytes: limits.saveBytes ?? null,
39
+ };
40
+ // Whether this caller may upload to THIS document, which is the question a
41
+ // client has to answer before it offers someone a file picker. Uploading is
42
+ // writing to the document by another route, so it is the write permission that
43
+ // decides it, never a separate one. `allowed: false` is still an answer and is
44
+ // reported as one: a client that sees no `upload` key at all cannot tell a host
45
+ // that refuses this person from a host that has no uploads.
46
+ if (capabilities.upload !== false) {
47
+ block.upload = writable
48
+ ? { allowed: true, maxBytes: limits.uploadBytes ?? null }
49
+ : { allowed: false };
50
+ }
51
+ return block;
52
+ }
@@ -0,0 +1,115 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ // Reading the Malleable HTML File wire contract off an Express request. One home
4
+ // so the save lane, the upload lane and the sync relay cannot drift on how a
5
+ // request names its target document or on which body shapes count.
6
+
7
+ // Spec §3: a request names the document it targets with a `Document-URL` header.
8
+ // Every shipping client still sends the pre-spec `Page-URL` (clayjs 0.2.0 sends
9
+ // only that), so both are read and `Document-URL` wins.
10
+ export function documentUrlHeader(req) {
11
+ return req?.headers?.['document-url'] || req?.headers?.['page-url'] || null;
12
+ }
13
+
14
+ // Spec §3: `/_/save` takes the document as text and has exactly one body shape,
15
+ // so a JSON envelope is refused rather than guessed at. Compares the media type
16
+ // only, ignoring parameters like `; charset=utf-8`, and covers the `+json`
17
+ // structured suffix.
18
+ export function isJsonContentType(contentType) {
19
+ const mediaType = String(contentType || '').split(';')[0].trim().toLowerCase();
20
+ return mediaType === 'application/json' || mediaType.endsWith('+json');
21
+ }
22
+
23
+ // Spec §3: the save body is the document, as text. One route, one body shape.
24
+ // Anything else a save needs to say travels in a header, which is how §6's
25
+ // `conditional` capability added `If-Match` without touching the body. A JSON
26
+ // body is refused with 415 rather than guessed at: a host that quietly wrote the
27
+ // envelope's `content` would be inventing a second shape for the one route the
28
+ // spec says has only one.
29
+ export function readSaveBody(req) {
30
+ if (isJsonContentType(req.headers['content-type'])) {
31
+ return { ok: false, code: 'unsupported-media-type', msg: '/_/save takes the document as text, not JSON.' };
32
+ }
33
+ return { ok: true, content: typeof req.body === 'string' ? req.body : null };
34
+ }
35
+
36
+ // Spec §6: an `etag` stamps the bytes the host STORED, never the bytes it was
37
+ // sent. The two differ whenever a document carries formathtml="true" and is
38
+ // reformatted before storage, so stamping the sent bytes would tell a client its
39
+ // disk contains something it does not.
40
+ export function documentEtag(storedHtml) {
41
+ return crypto.createHash('sha256').update(storedHtml ?? '').digest('hex').substring(0, 16);
42
+ }
43
+
44
+ // Does an If-Match field value permit writing over these stored bytes?
45
+ //
46
+ // Our own clients send one bare stamp, but RFC 9110 §13.1.1 spells the field as
47
+ // `*` or a comma-separated list of entity-tags, each optionally quoted and weak.
48
+ // A third-party client on a public spec that follows HTTP would otherwise get a
49
+ // 412 it could never explain. `*` asks only that the document exist at all. Weak
50
+ // tags count as matches, which is looser than the RFC's strong comparison and
51
+ // deliberate: our stamp is a digest of the exact stored bytes, so there is no
52
+ // weak form of it to confuse it with.
53
+ //
54
+ // An empty or absent field value matches nothing, so a client that computed its
55
+ // stamp wrong is refused rather than quietly dropped back to last-write-wins.
56
+ export function ifMatchSatisfied(fieldValue, storedBytes) {
57
+ const stored = storedBytes || '';
58
+ const field = String(fieldValue ?? '').trim();
59
+ if (field === '*') return stored.length > 0;
60
+
61
+ const current = documentEtag(stored);
62
+ return field.split(',').some((entry) => {
63
+ const tag = entry.trim().replace(/^W\//i, '').replace(/^"(.*)"$/, '$1');
64
+ return tag !== '' && tag === current;
65
+ });
66
+ }
67
+
68
+ export function ifMatchHeader(req) {
69
+ return req?.headers?.['if-match'] ?? null;
70
+ }
71
+
72
+ // Spec §9: advisory provenance. `user` is a human gesture, `auto` is autosave or
73
+ // script, and an unknown value reads as `auto`. Never security: document code
74
+ // can say anything.
75
+ export function saveTrigger(req) {
76
+ const raw = String(req?.headers?.['save-trigger'] || '').trim().toLowerCase();
77
+ if (raw === 'user') return 'user';
78
+ if (raw) return 'auto';
79
+ // clayjs 0.2.0 does not send Save-Trigger; it sends the platform's older
80
+ // provenance bit instead. [wire-compat]
81
+ const legacy = req?.headers?.['x-hyperclay-user-driven'];
82
+ if (legacy === '1') return 'user';
83
+ return 'auto';
84
+ }
85
+
86
+ // The path portion of a document URL, with the scheme, authority, query and
87
+ // fragment removed and NOTHING else done to it.
88
+ //
89
+ // Deliberately not `new URL(href).pathname`: WHATWG normalization resolves
90
+ // `..` segments and folds backslashes into slashes BEFORE any validation could
91
+ // see them, which turns `Document-URL: http://host/../../etc/passwd` into a
92
+ // silently accepted write instead of a refusal. The header is untrusted input
93
+ // chosen by the page (spec §8), so it is validated in the shape it arrived in.
94
+ export function rawDocumentPath(href) {
95
+ const raw = String(href ?? '');
96
+ const authority = raw.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/?#]*(.*)$/);
97
+ const withoutOrigin = authority ? authority[1] : raw;
98
+ const path = withoutOrigin.split('#')[0].split('?')[0];
99
+ return path === '' ? '/' : path;
100
+ }
101
+
102
+ // The realm-relative document a request names. Takes an ALREADY-DECODED path:
103
+ // decoding happens exactly once, in the caller, for the same reason the read
104
+ // lane decodes once. A browser sends `Document-URL: .../50%25%20off.html`, and
105
+ // decoding zero times lands the save in a new file literally named
106
+ // `50%25%20off.html`; decoding twice turns `%252e%252e` back into `..`.
107
+ export function resolveDocumentHref(decodedPath) {
108
+ let target = String(decodedPath ?? '').replace(/^\/+/, '');
109
+ if (target === '') return 'index.html';
110
+ if (target.endsWith('/')) return `${target}index.html`;
111
+
112
+ // Collapse an SPA tail: `blog/app.html/dashboard` addresses `blog/app.html`.
113
+ const match = target.match(/^(.*?\.(?:html|htmlclay))(?:\/.*)?$/i);
114
+ return match ? match[1] : target;
115
+ }
@@ -0,0 +1,29 @@
1
+ // The store-driver interface.
2
+ //
3
+ // The store is an OVERLAY, not a mirror: a row exists only for state a file
4
+ // cannot hold. Every document still serves, every version still lists and
5
+ // restores, with the store deleted. `store: null` is a supported configuration
6
+ // (no shares, no private, no tenants, no collections, no dashboard state) and
7
+ // the host is still a conforming spec host in it.
8
+ //
9
+ // A driver implements:
10
+ //
11
+ // open({ paths, clock }) -> void, applies migrations
12
+ // close() -> void
13
+ // tx(fn) -> runs fn inside one transaction
14
+ // nextSeq() -> number, the global change cursor
15
+ //
16
+ // getNode(owner, path) -> row | null (live rows only)
17
+ // getNodeById(id) -> row | null (including tombstoned)
18
+ // listNodes({ owner, prefix, includeDeleted, sinceSeq }) -> row[]
19
+ // insertNode(row) -> row
20
+ // updateNode(id, patch) -> row | null
21
+ // tombstone(id, at) -> row | null
22
+ // deleteNode(id) -> boolean
23
+ //
24
+ // getKv(key) / setKv(key, value) / deleteKv(key)
25
+ //
26
+ // plus table CRUD for shares, tenants, sessions, save_tokens, collections,
27
+ // record_auth and operations (see sqlite.js for the shapes).
28
+
29
+ export { createSqliteStore } from './sqlite.js';
@@ -0,0 +1,114 @@
1
+ -- The overlay. A row exists only for a path that has state or history.
2
+ CREATE TABLE nodes (
3
+ id TEXT PRIMARY KEY,
4
+ owner TEXT NOT NULL DEFAULT '',
5
+ path TEXT NOT NULL,
6
+ kind TEXT NOT NULL,
7
+ -- Two digests, two meanings. The rule that keeps them apart: THE SCANNER
8
+ -- NEVER WRITES A COLUMN A CLIENT-FACING DECISION READS. It used to write
9
+ -- `etag`, so once it had seen a terminal edit the kernel compared the file
10
+ -- against itself, found no drift, and let a stale tab destroy that edit.
11
+ -- etag the bytes the KERNEL last wrote. Null means the host has
12
+ -- never vouched for this file, which is what decides the
13
+ -- default sandbox. Also the drift baseline: bytes on disk
14
+ -- that do not match it were put there by something else.
15
+ -- observed_etag the bytes the SCANNER last saw on disk. Read by nothing
16
+ -- that answers a request.
17
+ etag TEXT,
18
+ observed_etag TEXT,
19
+ bytes INTEGER,
20
+ mtime_ms REAL,
21
+ -- Identity, in a later step: (dev, ino) is how a file is recognised after it
22
+ -- moves. `ino` is TEXT holding a canonical decimal string, because a Windows
23
+ -- NTFS file index is 64-bit and collapses above 2^53 as a JS number.
24
+ dev INTEGER,
25
+ ino TEXT,
26
+ seq INTEGER NOT NULL,
27
+ created_at INTEGER NOT NULL,
28
+ updated_at INTEGER NOT NULL,
29
+ deleted_at INTEGER,
30
+ private INTEGER NOT NULL DEFAULT 0,
31
+ isolated INTEGER,
32
+ signups INTEGER NOT NULL DEFAULT 0,
33
+ source_id TEXT REFERENCES nodes(id) ON DELETE SET NULL
34
+ );
35
+ -- Uniqueness holds over LIVE rows only. A tombstoned row keeps its original
36
+ -- path, because that is how restore knows where the bytes came from, so a
37
+ -- table-level UNIQUE(owner, path) would make a trashed name permanently
38
+ -- unusable: trash `todo.html`, and you could never create another one.
39
+ CREATE UNIQUE INDEX nodes_live_path ON nodes(owner, path) WHERE deleted_at IS NULL;
40
+ CREATE INDEX nodes_live ON nodes(owner, deleted_at, path);
41
+ CREATE INDEX nodes_seq ON nodes(seq);
42
+ -- Deliberately not unique: hardlinks give two live paths one inode.
43
+ CREATE INDEX nodes_inode ON nodes(dev, ino) WHERE deleted_at IS NULL;
44
+
45
+ CREATE TABLE shares (
46
+ token_hash TEXT PRIMARY KEY,
47
+ node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
48
+ grant_kind TEXT NOT NULL,
49
+ label TEXT,
50
+ created_at INTEGER NOT NULL,
51
+ expires_at INTEGER,
52
+ revoked_at INTEGER,
53
+ last_used_at INTEGER
54
+ );
55
+ CREATE INDEX shares_node ON shares(node_id);
56
+
57
+ CREATE TABLE tenants (
58
+ username TEXT PRIMARY KEY,
59
+ password_hash TEXT NOT NULL,
60
+ recovery_hash TEXT,
61
+ source_id TEXT REFERENCES nodes(id) ON DELETE SET NULL,
62
+ created_at INTEGER NOT NULL,
63
+ approved_at INTEGER,
64
+ disabled_at INTEGER,
65
+ bytes_used INTEGER NOT NULL DEFAULT 0
66
+ );
67
+
68
+ CREATE TABLE sessions (
69
+ token_hash TEXT PRIMARY KEY,
70
+ principal TEXT NOT NULL,
71
+ created_at INTEGER NOT NULL,
72
+ expires_at INTEGER NOT NULL,
73
+ last_seen_at INTEGER
74
+ );
75
+ CREATE INDEX sessions_gc ON sessions(expires_at);
76
+
77
+ CREATE TABLE save_tokens (
78
+ token TEXT PRIMARY KEY,
79
+ node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
80
+ principal TEXT,
81
+ share_hash TEXT REFERENCES shares(token_hash) ON DELETE CASCADE,
82
+ grant_kind TEXT NOT NULL,
83
+ expires_at INTEGER NOT NULL
84
+ );
85
+ CREATE INDEX save_tokens_gc ON save_tokens(expires_at);
86
+ CREATE INDEX save_tokens_node ON save_tokens(node_id);
87
+
88
+ CREATE TABLE collections (
89
+ node_id TEXT PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
90
+ slug TEXT UNIQUE NOT NULL,
91
+ submit_token TEXT NOT NULL,
92
+ accepting INTEGER NOT NULL DEFAULT 1,
93
+ role_map TEXT,
94
+ created_at INTEGER NOT NULL
95
+ );
96
+
97
+ CREATE TABLE record_auth (
98
+ node_id TEXT PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
99
+ code_hash TEXT NOT NULL,
100
+ failures INTEGER NOT NULL DEFAULT 0,
101
+ locked_until INTEGER,
102
+ deleted_at INTEGER
103
+ );
104
+
105
+ CREATE TABLE operations (
106
+ id TEXT PRIMARY KEY,
107
+ kind TEXT NOT NULL,
108
+ node_id TEXT,
109
+ phase TEXT NOT NULL,
110
+ payload_json TEXT NOT NULL,
111
+ started_at INTEGER NOT NULL
112
+ );
113
+
114
+ CREATE TABLE kv (k TEXT PRIMARY KEY, v TEXT);