@terrafirmagreg-team/data 0.1.0 → 1.2.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/package.json +10 -14
- package/src/lib/app-token.ts +70 -0
- package/src/lib/async-cache.ts +49 -0
- package/src/lib/cache-api.ts +17 -0
- package/src/lib/cookie.ts +1 -0
- package/src/lib/d1.ts +22 -0
- package/src/lib/db.ts +143 -0
- package/src/lib/env.ts +1 -1
- package/src/lib/github.ts +1 -0
- package/src/lib/index.ts +4 -1
- package/src/lib/json-file.ts +7 -3
- package/src/lib/kv.ts +0 -15
package/package.json
CHANGED
|
@@ -1,27 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@terrafirmagreg-team/data",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"type": "module",
|
|
5
|
+
"description": "Storage drivers and shared helpers",
|
|
6
|
+
"license": "LGPL-3.0-only",
|
|
5
7
|
"exports": {
|
|
6
8
|
".": "./src/lib/index.ts",
|
|
7
|
-
"
|
|
8
|
-
"./src/*.ts",
|
|
9
|
-
"./src
|
|
10
|
-
"./src/*/index.ts",
|
|
11
|
-
"./src/*/index.tsx",
|
|
12
|
-
"./src/*/*.ts",
|
|
13
|
-
"./src/*/*.tsx",
|
|
14
|
-
"./src/*/*/index.ts",
|
|
15
|
-
"./src/*/*/index.tsx"
|
|
9
|
+
"./lib/*": [
|
|
10
|
+
"./src/lib/*.ts",
|
|
11
|
+
"./src/lib/*/index.ts"
|
|
16
12
|
]
|
|
17
13
|
},
|
|
18
14
|
"files": [
|
|
19
|
-
"src"
|
|
20
|
-
"styles",
|
|
21
|
-
"styles.css",
|
|
22
|
-
"locales"
|
|
15
|
+
"src"
|
|
23
16
|
],
|
|
24
17
|
"dependencies": {
|
|
18
|
+
"fumadb": "0.6.0",
|
|
19
|
+
"kysely": "^0.29.8",
|
|
20
|
+
"kysely-d1": "^0.4.0",
|
|
25
21
|
"server-only": "^0.0.1"
|
|
26
22
|
},
|
|
27
23
|
"devDependencies": {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { env } from './env';
|
|
2
|
+
import { githubFetch } from './github';
|
|
3
|
+
|
|
4
|
+
const TOKEN_TTL_MS = 50 * 60 * 1000;
|
|
5
|
+
const cache = new Map<string, { token: Promise<string>; exp: number }>();
|
|
6
|
+
|
|
7
|
+
function pemToDer(pem: string): ArrayBuffer {
|
|
8
|
+
const b64 = pem.replace(/\\n/g, '\n').replace(/-----(?:BEGIN|END)[^\n-]*-----|\s/g, '');
|
|
9
|
+
const bin = Buffer.from(b64, 'base64');
|
|
10
|
+
return bin.buffer.slice(
|
|
11
|
+
bin.byteOffset,
|
|
12
|
+
bin.byteOffset + bin.byteLength,
|
|
13
|
+
) as ArrayBuffer;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function appJwt(): Promise<string> {
|
|
17
|
+
const appId = env('APP_ID');
|
|
18
|
+
const pem = env('APP_PRIVATE_KEY');
|
|
19
|
+
if (!appId || !pem)
|
|
20
|
+
throw new Error('APP_ID / APP_PRIVATE_KEY are not configured');
|
|
21
|
+
const now = Math.floor(Date.now() / 1000);
|
|
22
|
+
const pack = (value: object) =>
|
|
23
|
+
Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
24
|
+
const payload = `${pack({ alg: 'RS256', typ: 'JWT' })}.${pack({ iat: now - 60, exp: now + 540, iss: appId })}`;
|
|
25
|
+
const key = await crypto.subtle.importKey(
|
|
26
|
+
'pkcs8',
|
|
27
|
+
pemToDer(pem),
|
|
28
|
+
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
|
|
29
|
+
false,
|
|
30
|
+
['sign'],
|
|
31
|
+
);
|
|
32
|
+
const signature = await crypto.subtle.sign(
|
|
33
|
+
'RSASSA-PKCS1-v1_5',
|
|
34
|
+
key,
|
|
35
|
+
new TextEncoder().encode(payload),
|
|
36
|
+
);
|
|
37
|
+
return `${payload}.${Buffer.from(signature).toString('base64url')}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function mintInstallationToken(repo: string): Promise<string> {
|
|
41
|
+
const jwt = await appJwt();
|
|
42
|
+
const installation = await githubFetch(`/repos/${repo}/installation`, {
|
|
43
|
+
token: jwt,
|
|
44
|
+
});
|
|
45
|
+
if (!installation.ok)
|
|
46
|
+
throw new Error(`app installation lookup ${repo}: ${installation.status}`);
|
|
47
|
+
const { id } = (await installation.json()) as { id: number };
|
|
48
|
+
const response = await githubFetch(
|
|
49
|
+
`/app/installations/${id}/access_tokens`,
|
|
50
|
+
{ token: jwt, method: 'POST' },
|
|
51
|
+
);
|
|
52
|
+
if (!response.ok)
|
|
53
|
+
throw new Error(`app installation token ${repo}: ${response.status}`);
|
|
54
|
+
return ((await response.json()) as { token: string }).token;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function githubAppToken(repo: string): Promise<string> {
|
|
58
|
+
const cached = cache.get(repo);
|
|
59
|
+
if (cached && cached.exp > Date.now()) return cached.token;
|
|
60
|
+
const entry = {
|
|
61
|
+
token: mintInstallationToken(repo).catch((err) => {
|
|
62
|
+
cache.delete(repo);
|
|
63
|
+
throw err;
|
|
64
|
+
}),
|
|
65
|
+
exp: Date.now() + TOKEN_TTL_MS,
|
|
66
|
+
};
|
|
67
|
+
cache.set(repo, entry);
|
|
68
|
+
return entry.token;
|
|
69
|
+
}
|
|
70
|
+
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export type AsyncCache<A extends unknown[], V> = ((
|
|
2
|
+
...args: A
|
|
3
|
+
) => Promise<V>) & {
|
|
4
|
+
peek: (...args: A) => V | undefined;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function createAsyncCache<A extends unknown[], V>(
|
|
8
|
+
loader: (...args: A) => Promise<V>,
|
|
9
|
+
keyOf: (...args: A) => string = (...args) => JSON.stringify(args),
|
|
10
|
+
ttl?: number,
|
|
11
|
+
): AsyncCache<A, V> {
|
|
12
|
+
const cache = new Map<string, Promise<V>>();
|
|
13
|
+
const resolved = new Map<string, V>();
|
|
14
|
+
const expiry = new Map<string, number>();
|
|
15
|
+
const expired = (key: string) => {
|
|
16
|
+
const at = expiry.get(key);
|
|
17
|
+
return at !== undefined && at <= Date.now();
|
|
18
|
+
};
|
|
19
|
+
const call = (...args: A) => {
|
|
20
|
+
const key = keyOf(...args);
|
|
21
|
+
let pending = cache.get(key);
|
|
22
|
+
if (pending && expired(key)) {
|
|
23
|
+
cache.delete(key);
|
|
24
|
+
resolved.delete(key);
|
|
25
|
+
expiry.delete(key);
|
|
26
|
+
pending = undefined;
|
|
27
|
+
}
|
|
28
|
+
if (!pending) {
|
|
29
|
+
pending = loader(...args)
|
|
30
|
+
.then((value) => {
|
|
31
|
+
resolved.set(key, value);
|
|
32
|
+
if (ttl !== undefined) expiry.set(key, Date.now() + ttl);
|
|
33
|
+
return value;
|
|
34
|
+
})
|
|
35
|
+
.catch((err) => {
|
|
36
|
+
cache.delete(key);
|
|
37
|
+
throw err;
|
|
38
|
+
});
|
|
39
|
+
cache.set(key, pending);
|
|
40
|
+
}
|
|
41
|
+
return pending;
|
|
42
|
+
};
|
|
43
|
+
call.peek = (...args: A) => {
|
|
44
|
+
const key = keyOf(...args);
|
|
45
|
+
return expired(key) ? undefined : resolved.get(key);
|
|
46
|
+
};
|
|
47
|
+
return call;
|
|
48
|
+
}
|
|
49
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
|
|
3
|
+
export async function cachedResponse(
|
|
4
|
+
_req: Request,
|
|
5
|
+
ttl: number,
|
|
6
|
+
load: () => Promise<Response>,
|
|
7
|
+
): Promise<Response> {
|
|
8
|
+
const res = await load();
|
|
9
|
+
if (!res.ok) return res;
|
|
10
|
+
const headers = new Headers(res.headers);
|
|
11
|
+
headers.set(
|
|
12
|
+
'Cache-Control',
|
|
13
|
+
`public, s-maxage=${ttl}, stale-while-revalidate=${ttl}`,
|
|
14
|
+
);
|
|
15
|
+
return new Response(res.body, { status: res.status, headers });
|
|
16
|
+
}
|
|
17
|
+
|
package/src/lib/cookie.ts
CHANGED
package/src/lib/d1.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type D1PreparedStatement = {
|
|
2
|
+
bind(...values: unknown[]): D1PreparedStatement;
|
|
3
|
+
first<T>(column?: string): Promise<T | null>;
|
|
4
|
+
run<T>(): Promise<T>;
|
|
5
|
+
all<T>(): Promise<{
|
|
6
|
+
results: T[];
|
|
7
|
+
meta?: { changes?: number; last_row_id?: number };
|
|
8
|
+
}>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type D1Binding = {
|
|
12
|
+
prepare(query: string): D1PreparedStatement;
|
|
13
|
+
exec?(query: string): Promise<unknown>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function d1Binding(name: string): D1Binding | undefined {
|
|
17
|
+
const db = (process.env as Record<string, unknown>)[name];
|
|
18
|
+
return db && typeof (db as D1Binding).prepare === 'function'
|
|
19
|
+
? (db as D1Binding)
|
|
20
|
+
: undefined;
|
|
21
|
+
}
|
|
22
|
+
|
package/src/lib/db.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
import { fumadb, type InferFumaDB } from 'fumadb';
|
|
3
|
+
import { kyselyAdapter } from 'fumadb/adapters/kysely';
|
|
4
|
+
import { column, idColumn, schema, table } from 'fumadb/schema';
|
|
5
|
+
import { Kysely, sql } from 'kysely';
|
|
6
|
+
import { D1Dialect } from 'kysely-d1';
|
|
7
|
+
import { d1Binding, type D1Binding, type D1PreparedStatement } from './d1';
|
|
8
|
+
import { env } from './env';
|
|
9
|
+
|
|
10
|
+
const v1 = schema({
|
|
11
|
+
version: '1.0.0',
|
|
12
|
+
tables: {
|
|
13
|
+
shares: table('shares', {
|
|
14
|
+
scope: idColumn('scope', 'varchar(255)'),
|
|
15
|
+
tree: column('tree', 'string'),
|
|
16
|
+
created: column('created', 'integer'),
|
|
17
|
+
}),
|
|
18
|
+
analytics: table('analytics', {
|
|
19
|
+
key: idColumn('key', 'varchar(255)'),
|
|
20
|
+
views: column('views', 'integer'),
|
|
21
|
+
}),
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const DB_NAMESPACE = env('DB_NAME') ?? 'wiki';
|
|
26
|
+
|
|
27
|
+
export const AppDB = fumadb({ namespace: DB_NAMESPACE, schemas: [v1] });
|
|
28
|
+
|
|
29
|
+
export type AppDbClient = InferFumaDB<typeof AppDB>;
|
|
30
|
+
export type DataOrm = ReturnType<AppDbClient['orm']>;
|
|
31
|
+
|
|
32
|
+
type D1RestResult = {
|
|
33
|
+
results?: Record<string, unknown>[];
|
|
34
|
+
success?: boolean;
|
|
35
|
+
meta?: { changes?: number; last_row_id?: number };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function remoteD1({
|
|
39
|
+
accountId,
|
|
40
|
+
databaseId,
|
|
41
|
+
token,
|
|
42
|
+
}: {
|
|
43
|
+
accountId: string;
|
|
44
|
+
databaseId: string;
|
|
45
|
+
token: string;
|
|
46
|
+
}): D1Binding {
|
|
47
|
+
const call = async (sql: string, params: unknown[]) => {
|
|
48
|
+
const res = await fetch(
|
|
49
|
+
`https://api.cloudflare.com/client/v4/accounts/${accountId}/d1/database/${databaseId}/query`,
|
|
50
|
+
{
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: {
|
|
53
|
+
Authorization: `Bearer ${token}`,
|
|
54
|
+
'Content-Type': 'application/json',
|
|
55
|
+
},
|
|
56
|
+
body: JSON.stringify({ sql, params }),
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
if (!res.ok) throw new Error(`D1 REST ${res.status}: ${await res.text()}`);
|
|
60
|
+
const body = (await res.json()) as { result?: D1RestResult[] };
|
|
61
|
+
const first = body.result?.[0];
|
|
62
|
+
if (!first?.success) throw new Error('D1 query failed');
|
|
63
|
+
return first;
|
|
64
|
+
};
|
|
65
|
+
return {
|
|
66
|
+
prepare: (sql) => {
|
|
67
|
+
let bound: unknown[] = [];
|
|
68
|
+
const statement: D1PreparedStatement = {
|
|
69
|
+
bind: (...params: unknown[]) => {
|
|
70
|
+
bound = params;
|
|
71
|
+
return statement;
|
|
72
|
+
},
|
|
73
|
+
all: async <T,>() => {
|
|
74
|
+
const res = await call(sql, bound);
|
|
75
|
+
return { results: (res.results ?? []) as T[], meta: res.meta };
|
|
76
|
+
},
|
|
77
|
+
first: async <T,>(column?: string) => {
|
|
78
|
+
const row = (await call(sql, bound)).results?.[0] as
|
|
79
|
+
| T
|
|
80
|
+
| undefined;
|
|
81
|
+
if (row == null || !column) return (row ?? null) as T | null;
|
|
82
|
+
return (row as Record<string, unknown>)[column] as T;
|
|
83
|
+
},
|
|
84
|
+
run: async <T,>() => (await call(sql, bound)) as T,
|
|
85
|
+
};
|
|
86
|
+
return statement;
|
|
87
|
+
},
|
|
88
|
+
exec: async (sql) => call(sql, []),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function resolveD1(): D1Binding | undefined {
|
|
93
|
+
const bound = d1Binding(env('D1_BINDING') ?? 'DB');
|
|
94
|
+
if (bound) return bound;
|
|
95
|
+
const accountId = env('CLOUDFLARE_ACCOUNT_ID');
|
|
96
|
+
const databaseId = env('CLOUDFLARE_D1_DATABASE_ID');
|
|
97
|
+
const token = env('CLOUDFLARE_API_TOKEN');
|
|
98
|
+
return accountId && databaseId && token
|
|
99
|
+
? remoteD1({ accountId, databaseId, token })
|
|
100
|
+
: undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const DDL = [
|
|
104
|
+
'CREATE TABLE IF NOT EXISTS shares (scope TEXT PRIMARY KEY, tree TEXT NOT NULL, created INTEGER NOT NULL)',
|
|
105
|
+
'CREATE TABLE IF NOT EXISTS analytics (key TEXT PRIMARY KEY, views INTEGER NOT NULL)',
|
|
106
|
+
`CREATE TABLE IF NOT EXISTS private_${DB_NAMESPACE}_settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)`,
|
|
107
|
+
`INSERT OR IGNORE INTO private_${DB_NAMESPACE}_settings (key, value) VALUES ('version', '1.0.0')`,
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
let ready: Promise<AppDbClient | undefined> | undefined;
|
|
111
|
+
let kyselyDb: Kysely<unknown> | undefined;
|
|
112
|
+
|
|
113
|
+
export function dbKysely(): Kysely<unknown> | undefined {
|
|
114
|
+
return kyselyDb;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function dbSql(strings: TemplateStringsArray, ...values: unknown[]) {
|
|
118
|
+
const db = kyselyDb;
|
|
119
|
+
return db ? sql(strings, ...values).execute(db) : undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function dataDb(): Promise<AppDbClient | undefined> {
|
|
123
|
+
const p = (ready ??= (async () => {
|
|
124
|
+
const d1 = resolveD1();
|
|
125
|
+
if (!d1) return undefined;
|
|
126
|
+
for (const sql of DDL) await d1.exec?.(sql);
|
|
127
|
+
const db = new Kysely({
|
|
128
|
+
dialect: new D1Dialect({ database: d1 as never }),
|
|
129
|
+
});
|
|
130
|
+
kyselyDb = db;
|
|
131
|
+
return AppDB.client(kyselyAdapter({ provider: 'sqlite', db }));
|
|
132
|
+
})());
|
|
133
|
+
p.catch(() => {
|
|
134
|
+
if (ready === p) ready = undefined;
|
|
135
|
+
});
|
|
136
|
+
return p;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function dbOrm(): Promise<DataOrm | undefined> {
|
|
140
|
+
const client = await dataDb();
|
|
141
|
+
return client ? client.orm(await client.version()) : undefined;
|
|
142
|
+
}
|
|
143
|
+
|
package/src/lib/env.ts
CHANGED
package/src/lib/github.ts
CHANGED
package/src/lib/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { env } from './env';
|
|
2
|
-
export {
|
|
2
|
+
export { d1Binding, type D1Binding } from './d1';
|
|
3
|
+
export { dataDb, dbOrm, dbSql, dbKysely, AppDB, type AppDbClient, type DataOrm } from './db';
|
|
4
|
+
export { githubAppToken } from './app-token';
|
|
3
5
|
export { loadJsonFile, saveJsonFile, jsonFileStore } from './json-file';
|
|
4
6
|
export {
|
|
5
7
|
githubFetch,
|
|
@@ -13,3 +15,4 @@ export {
|
|
|
13
15
|
parseCookies,
|
|
14
16
|
readTokenFromCookieHeader,
|
|
15
17
|
} from './cookie';
|
|
18
|
+
|
package/src/lib/json-file.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { dirname } from 'node:path';
|
|
|
5
5
|
export function loadJsonFile<T>(file: string, fallback: () => T): T {
|
|
6
6
|
try {
|
|
7
7
|
return JSON.parse(
|
|
8
|
-
readFileSync( file, 'utf8'),
|
|
8
|
+
readFileSync(/* turbopackIgnore: true */ file, 'utf8'),
|
|
9
9
|
) as T;
|
|
10
10
|
} catch {
|
|
11
11
|
return fallback();
|
|
@@ -13,8 +13,11 @@ export function loadJsonFile<T>(file: string, fallback: () => T): T {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export function saveJsonFile(file: string, data: unknown, space?: number): void {
|
|
16
|
-
mkdirSync(dirname(file), { recursive: true });
|
|
17
|
-
writeFileSync(
|
|
16
|
+
mkdirSync(/* turbopackIgnore: true */ dirname(file), { recursive: true });
|
|
17
|
+
writeFileSync(
|
|
18
|
+
/* turbopackIgnore: true */ file,
|
|
19
|
+
JSON.stringify(data, null, space),
|
|
20
|
+
);
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
export function jsonFileStore<T>(file: string, fallback: () => T, space?: number) {
|
|
@@ -29,3 +32,4 @@ export function jsonFileStore<T>(file: string, fallback: () => T, space?: number
|
|
|
29
32
|
};
|
|
30
33
|
return store;
|
|
31
34
|
}
|
|
35
|
+
|
package/src/lib/kv.ts
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
export type KvBinding = {
|
|
2
|
-
get(key: string): Promise<string | null>;
|
|
3
|
-
put(
|
|
4
|
-
key: string,
|
|
5
|
-
value: string,
|
|
6
|
-
opts?: { expirationTtl?: number },
|
|
7
|
-
): Promise<void>;
|
|
8
|
-
};
|
|
9
|
-
|
|
10
|
-
export function kvBinding(name: string): KvBinding | undefined {
|
|
11
|
-
const kv = (process.env as Record<string, unknown>)[name];
|
|
12
|
-
return kv && typeof (kv as KvBinding).get === 'function'
|
|
13
|
-
? (kv as KvBinding)
|
|
14
|
-
: undefined;
|
|
15
|
-
}
|