laravel-workers 0.4.0 → 0.4.1
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/LICENSE +21 -21
- package/bin/laravel-workers.mjs +141 -141
- package/build.mjs +2599 -2599
- package/compiler/bin/controller.mjs +92 -92
- package/kernel/cache.mjs +405 -405
- package/kernel/db.mjs +703 -703
- package/kernel/encrypter.mjs +1 -1
- package/kernel/hash.mjs +113 -113
- package/kernel/multipart.mjs +161 -161
- package/kernel/router.mjs +276 -276
- package/kernel/scheduler.mjs +236 -236
- package/kernel/str.mjs +445 -445
- package/kernel/tests/cache.test.mjs +290 -290
- package/kernel/tests/db.test.mjs +1068 -1068
- package/kernel/tests/encrypter.test.mjs +1 -1
- package/kernel/tests/hash.test.mjs +149 -149
- package/kernel/tests/http.test.mjs +1182 -1182
- package/kernel/tests/php-json.test.mjs +263 -263
- package/kernel/tests/router.test.mjs +281 -281
- package/kernel/tests/services.test.mjs +474 -474
- package/kernel/tests/str.test.mjs +83 -83
- package/kernel/tests/validation.test.mjs +340 -340
- package/kernel/validation.mjs +123 -123
- package/package.json +69 -69
- package/tools/schema-dump.php +158 -158
package/kernel/cache.mjs
CHANGED
|
@@ -1,405 +1,405 @@
|
|
|
1
|
-
// kernel/cache.mjs — Cache (KV or memory+TTL), with tags
|
|
2
|
-
//
|
|
3
|
-
// Also ports Laravel's `database` cache store (Illuminate\Cache\DatabaseStore,
|
|
4
|
-
// 13.37) to D1 — the driver the compiled rung uses for `throttle:n,m` and the
|
|
5
|
-
// `Cache` facade. Semantics mirror the vendor line-for-line:
|
|
6
|
-
// - keys are prefixed inside the store (Repository::itemKey is the identity);
|
|
7
|
-
// - values are `serialize()`d, and a NUL byte triggers base64 (the SQLite /
|
|
8
|
-
// Postgres rule — compare `Illuminate\Cache\DatabaseStore::serialize`);
|
|
9
|
-
// - expiration is an integer second timestamp; expired rows are read-then-deleted;
|
|
10
|
-
// - `add` is INSERT OR IGNORE, `put` is an upsert, `forever` is +315360000s.
|
|
11
|
-
// Known divergence: D1 has no interactive transactions, so increment/decrement
|
|
12
|
-
// are a read-then-write (Laravel wraps them in a transaction). Documented in COMPILED.md.
|
|
13
|
-
import { phpSerialize, phpUnserialize } from './php-serialize.mjs';
|
|
14
|
-
|
|
15
|
-
/** Illuminate\Cache\Repository::FLEXIBLE_CREATED_KEY_PREFIX */
|
|
16
|
-
export const FLEXIBLE_CREATED_KEY_PREFIX = 'illuminate:cache:flexible:created:';
|
|
17
|
-
const FOREVER_SECONDS = 315360000;
|
|
18
|
-
|
|
19
|
-
/** PHP is_numeric() for the value shapes a cache row can hold. */
|
|
20
|
-
function phpIsNumericValue(value) {
|
|
21
|
-
if (typeof value === 'number') return true;
|
|
22
|
-
if (typeof value === 'string') {
|
|
23
|
-
return /^[ \t\n\r\v\f]*[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?[ \t\n\r\v\f]*$/.test(value);
|
|
24
|
-
}
|
|
25
|
-
return false;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** PHP (int) cast for the value shapes a cache row can hold. */
|
|
29
|
-
function phpIntCastValue(value) {
|
|
30
|
-
if (value === null || value === undefined || value === false) return 0;
|
|
31
|
-
if (value === true) return 1;
|
|
32
|
-
if (typeof value === 'number') return Number.isFinite(value) ? Math.trunc(value) : 0;
|
|
33
|
-
if (typeof value === 'string') {
|
|
34
|
-
const m = /^[ \t\n\r\v\f]*([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)/.exec(value);
|
|
35
|
-
if (!m) return 0;
|
|
36
|
-
const n = /[.eE]/.test(m[1]) ? parseFloat(m[1]) : parseInt(m[1], 10);
|
|
37
|
-
return Number.isFinite(n) ? Math.trunc(n) : 0;
|
|
38
|
-
}
|
|
39
|
-
return 0;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function phpValue(value) {
|
|
43
|
-
return typeof value === 'function' ? value() : value;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// PHP base64_encode/decode over the byte string. phpSerialize emits raw bytes as
|
|
47
|
-
// one-code-unit-per-byte; mask to keep the base64 alphabet on the original bytes.
|
|
48
|
-
function base64EncodeBytes(str) {
|
|
49
|
-
let bin = '';
|
|
50
|
-
for (let i = 0; i < str.length; i++) bin += String.fromCharCode(str.charCodeAt(i) & 0xff);
|
|
51
|
-
return btoa(bin);
|
|
52
|
-
}
|
|
53
|
-
function base64DecodeBytes(b64) {
|
|
54
|
-
const bin = atob(String(b64));
|
|
55
|
-
return bin;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Illuminate\Cache\DatabaseStore (+ the Repository-level methods the kernel's
|
|
60
|
-
* RateLimiter calls: get/has/put/add/increment/decrement/forever/forget).
|
|
61
|
-
*
|
|
62
|
-
* @param {object} db D1 binding (prepare/bind/all/first/run)
|
|
63
|
-
* @param {object} [options]
|
|
64
|
-
* @param {string} [options.table] cache table (default "cache")
|
|
65
|
-
* @param {string} [options.prefix] cache key prefix (config cache.prefix)
|
|
66
|
-
* @param {string} [options.lockTable] cache_locks table
|
|
67
|
-
* @param {() => number} [options.clock] integer-second clock
|
|
68
|
-
*/
|
|
69
|
-
export class DatabaseCacheStore {
|
|
70
|
-
constructor(db, options = {}) {
|
|
71
|
-
if (!db || typeof db.prepare !== 'function') {
|
|
72
|
-
throw new Error('DatabaseCacheStore: a D1 binding is required');
|
|
73
|
-
}
|
|
74
|
-
this.db = db;
|
|
75
|
-
this.table = options.table || 'cache';
|
|
76
|
-
this.prefix = options.prefix || '';
|
|
77
|
-
this.lockTable = options.lockTable || 'cache_locks';
|
|
78
|
-
this._clock = typeof options.clock === 'function' ? options.clock : () => Math.floor(Date.now() / 1000);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
getPrefix() {
|
|
82
|
-
return this.prefix;
|
|
83
|
-
}
|
|
84
|
-
setPrefix(prefix) {
|
|
85
|
-
this.prefix = String(prefix);
|
|
86
|
-
return this;
|
|
87
|
-
}
|
|
88
|
-
/** InteractsWithTime::currentTime() */
|
|
89
|
-
currentTime() {
|
|
90
|
-
return this._clock();
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** DatabaseStore::serialize() — base64 when the payload carries a NUL byte. */
|
|
94
|
-
_serialize(value) {
|
|
95
|
-
const result = phpSerialize(value);
|
|
96
|
-
return result.includes('\u0000') ? base64EncodeBytes(result) : result;
|
|
97
|
-
}
|
|
98
|
-
/** DatabaseStore::unserialize() — a value with no ":" / ";" is base64. */
|
|
99
|
-
_unserialize(value) {
|
|
100
|
-
let v = String(value);
|
|
101
|
-
if (!v.includes(':') && !v.includes(';')) v = base64DecodeBytes(v);
|
|
102
|
-
return phpUnserialize(v);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
async many(keys) {
|
|
106
|
-
const out = {};
|
|
107
|
-
for (const k of keys) out[k] = null;
|
|
108
|
-
if (keys.length === 0) return out;
|
|
109
|
-
const prefixed = keys.map((k) => this.prefix + k);
|
|
110
|
-
const placeholders = prefixed.map(() => '?').join(', ');
|
|
111
|
-
const { results } = await this.db
|
|
112
|
-
.prepare(`SELECT "key", "value", "expiration" FROM ${this.table} WHERE "key" IN (${placeholders})`)
|
|
113
|
-
.bind(...prefixed)
|
|
114
|
-
.all();
|
|
115
|
-
const now = this.currentTime();
|
|
116
|
-
const live = new Map();
|
|
117
|
-
const expired = [];
|
|
118
|
-
for (const row of results || []) {
|
|
119
|
-
if (row.expiration > now) live.set(row.key, row);
|
|
120
|
-
else expired.push(row.key);
|
|
121
|
-
}
|
|
122
|
-
if (expired.length) await this._forgetManyIfExpired(expired);
|
|
123
|
-
for (const k of keys) {
|
|
124
|
-
const row = live.get(this.prefix + k);
|
|
125
|
-
if (row) out[k] = this._unserialize(row.value);
|
|
126
|
-
}
|
|
127
|
-
return out;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** Repository::get(key, default) */
|
|
131
|
-
async get(key, defaultValue = null) {
|
|
132
|
-
const value = (await this.many([key]))[key];
|
|
133
|
-
return value === null ? phpValue(defaultValue) : value;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** Repository::has(key) — ! is_null(get(key)) */
|
|
137
|
-
async has(key) {
|
|
138
|
-
return (await this.get(key)) !== null;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** DatabaseStore::putMany() — one upsert per row (D1 has no multi-row upsert). */
|
|
142
|
-
async putMany(values, seconds) {
|
|
143
|
-
// Laravel's Repository::put() (Cache/Repository.php:367-380): a NULL ttl
|
|
144
|
-
// means "forever" ($this->forever($key, $value)); a ttl of 0 (or less)
|
|
145
|
-
// FORGETS the key (`if ($seconds <= 0) { return $this->forget($key); }`).
|
|
146
|
-
// The edge stored a ttl of 0 for TEN YEARS (now + 315360000) - a key that
|
|
147
|
-
// Laravel drops was kept forever.
|
|
148
|
-
const ttl = seconds === undefined || seconds === null ? null : phpIntCastValue(seconds);
|
|
149
|
-
if (ttl !== null && ttl <= 0) {
|
|
150
|
-
let forgotten = 0;
|
|
151
|
-
for (const key of Object.keys(values)) {
|
|
152
|
-
forgotten += await this.forget(key) ? 1 : 0;
|
|
153
|
-
}
|
|
154
|
-
return forgotten > 0;
|
|
155
|
-
}
|
|
156
|
-
const expiration = ttl === null ? this.currentTime() + FOREVER_SECONDS : this.currentTime() + ttl;
|
|
157
|
-
let changes = 0;
|
|
158
|
-
for (const [key, value] of Object.entries(values)) {
|
|
159
|
-
const res = await this.db
|
|
160
|
-
.prepare(
|
|
161
|
-
`INSERT INTO ${this.table} ("key", "value", "expiration") VALUES (?, ?, ?)
|
|
162
|
-
ON CONFLICT("key") DO UPDATE SET "value" = excluded."value", "expiration" = excluded."expiration"`
|
|
163
|
-
)
|
|
164
|
-
.bind(this.prefix + key, this._serialize(value), expiration)
|
|
165
|
-
.run();
|
|
166
|
-
changes += Number((res && res.meta && res.meta.changes) || 0);
|
|
167
|
-
}
|
|
168
|
-
return changes > 0;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/** DatabaseStore::put() */
|
|
172
|
-
async put(key, value, seconds) {
|
|
173
|
-
return this.putMany({ [key]: value }, seconds);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/** DatabaseStore::add() — INSERT OR IGNORE, false when the key already lives. */
|
|
177
|
-
async add(key, value, seconds) {
|
|
178
|
-
if ((await this.get(key)) !== null) return false;
|
|
179
|
-
const res = await this.db
|
|
180
|
-
.prepare(`INSERT OR IGNORE INTO ${this.table} ("key", "value", "expiration") VALUES (?, ?, ?)`)
|
|
181
|
-
.bind(
|
|
182
|
-
this.prefix + key,
|
|
183
|
-
this._serialize(value),
|
|
184
|
-
phpIntCastValue(seconds) > 0 ? this.currentTime() + phpIntCastValue(seconds) : this.currentTime() + FOREVER_SECONDS
|
|
185
|
-
)
|
|
186
|
-
.run();
|
|
187
|
-
return Number((res && res.meta && res.meta.changes) || 0) > 0;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/** DatabaseStore::forever() */
|
|
191
|
-
async forever(key, value) {
|
|
192
|
-
return this.put(key, value, FOREVER_SECONDS);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/** DatabaseStore::increment() */
|
|
196
|
-
async increment(key, amount = 1) {
|
|
197
|
-
return this._incrementOrDecrement(key, amount, (current, by) => current + by);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/** DatabaseStore::decrement() */
|
|
201
|
-
async decrement(key, amount = 1) {
|
|
202
|
-
return this._incrementOrDecrement(key, amount, (current, by) => current - by);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
async _incrementOrDecrement(key, amount, callback) {
|
|
206
|
-
const prefixed = this.prefix + key;
|
|
207
|
-
const row = await this.db
|
|
208
|
-
.prepare(`SELECT "value" FROM ${this.table} WHERE "key" = ?`)
|
|
209
|
-
.bind(prefixed)
|
|
210
|
-
.first();
|
|
211
|
-
if (row === null || row === undefined) return false;
|
|
212
|
-
const current = this._unserialize(row.value);
|
|
213
|
-
const next = callback(phpIntCastValue(current), phpIntCastValue(amount));
|
|
214
|
-
if (!phpIsNumericValue(current)) return false;
|
|
215
|
-
await this.db
|
|
216
|
-
.prepare(`UPDATE ${this.table} SET "value" = ? WHERE "key" = ?`)
|
|
217
|
-
.bind(this._serialize(next), prefixed)
|
|
218
|
-
.run();
|
|
219
|
-
return next;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/** DatabaseStore::remember() — get or compute+store. */
|
|
223
|
-
async remember(key, seconds, callback) {
|
|
224
|
-
const value = await this.get(key);
|
|
225
|
-
if (value !== null) return value;
|
|
226
|
-
const result = typeof callback === 'function' ? await callback() : callback;
|
|
227
|
-
await this.put(key, result, seconds);
|
|
228
|
-
return result;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/** Repository::pull() — get then forget. */
|
|
232
|
-
async pull(key, defaultValue = null) {
|
|
233
|
-
const value = await this.get(key);
|
|
234
|
-
await this.forget(key);
|
|
235
|
-
return value === null ? phpValue(defaultValue) : value;
|
|
236
|
-
}
|
|
237
|
-
/** DatabaseStore::forget() — also clears the flexible-created marker. */
|
|
238
|
-
async forget(key) {
|
|
239
|
-
const keys = [this.prefix + key, this.prefix + FLEXIBLE_CREATED_KEY_PREFIX + key];
|
|
240
|
-
await this.db
|
|
241
|
-
.prepare(`DELETE FROM ${this.table} WHERE "key" IN (?, ?)`)
|
|
242
|
-
.bind(...keys)
|
|
243
|
-
.run();
|
|
244
|
-
return true;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
async _forgetManyIfExpired(prefixedKeys) {
|
|
248
|
-
const keys = [];
|
|
249
|
-
for (const k of prefixedKeys) {
|
|
250
|
-
keys.push(k);
|
|
251
|
-
const bare = k.startsWith(this.prefix) ? k.slice(this.prefix.length) : k;
|
|
252
|
-
keys.push(this.prefix + FLEXIBLE_CREATED_KEY_PREFIX + bare);
|
|
253
|
-
}
|
|
254
|
-
const placeholders = keys.map(() => '?').join(', ');
|
|
255
|
-
await this.db
|
|
256
|
-
.prepare(`DELETE FROM ${this.table} WHERE "key" IN (${placeholders}) AND "expiration" <= ?`)
|
|
257
|
-
.bind(...keys, this.currentTime())
|
|
258
|
-
.run();
|
|
259
|
-
return true;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/** DatabaseStore::touch() */
|
|
263
|
-
async touch(key, seconds) {
|
|
264
|
-
const now = this.currentTime();
|
|
265
|
-
const res = await this.db
|
|
266
|
-
.prepare(`UPDATE ${this.table} SET "expiration" = ? WHERE "key" = ? AND "expiration" > ?`)
|
|
267
|
-
.bind(now + phpIntCastValue(seconds), this.prefix + key, now)
|
|
268
|
-
.run();
|
|
269
|
-
return Number((res && res.meta && res.meta.changes) || 0) > 0;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/** DatabaseStore::flush() */
|
|
273
|
-
async flush() {
|
|
274
|
-
await this.db.prepare(`DELETE FROM ${this.table}`).run();
|
|
275
|
-
return true;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/**
|
|
280
|
-
* Build the D1-backed cache store from the flat generated config
|
|
281
|
-
* (cache.driver / cache.table / cache.prefix / cache.lock_table).
|
|
282
|
-
*/
|
|
283
|
-
export function createDatabaseCache({ db, config = {}, clock } = {}) {
|
|
284
|
-
return new DatabaseCacheStore(db, {
|
|
285
|
-
table: config['cache.table'] || 'cache',
|
|
286
|
-
prefix: config['cache.prefix'] || '',
|
|
287
|
-
lockTable: config['cache.lock_table'] || 'cache_locks',
|
|
288
|
-
clock,
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
function normalizeTags(names) {
|
|
293
|
-
if (typeof names === 'string') names = [names];
|
|
294
|
-
if (!Array.isArray(names) || names.length === 0) throw new Error('tags requires a non-empty array');
|
|
295
|
-
return names.map(n => String(n));
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
export function createCache({ kv, ttlMs = 60000, secret } = {}) {
|
|
299
|
-
let base;
|
|
300
|
-
let readIndex, writeIndex;
|
|
301
|
-
|
|
302
|
-
if (kv && typeof kv.get === 'function') {
|
|
303
|
-
base = {
|
|
304
|
-
async get(key) { const v = await kv.get(key, 'text'); return v === null ? null : JSON.parse(v); },
|
|
305
|
-
async put(key, value, ttl) {
|
|
306
|
-
const opts = ttl ? { expirationTtl: Math.ceil(ttl / 1000) } : undefined;
|
|
307
|
-
await kv.put(key, JSON.stringify(value), opts);
|
|
308
|
-
},
|
|
309
|
-
async forget(key) { await kv.delete(key); },
|
|
310
|
-
async remember(key, ttl, cb) {
|
|
311
|
-
const hit = await kv.get(key, 'text');
|
|
312
|
-
if (hit !== null) return JSON.parse(hit);
|
|
313
|
-
const val = await cb();
|
|
314
|
-
await kv.put(key, JSON.stringify(val), { expirationTtl: Math.ceil(ttl / 1000) });
|
|
315
|
-
return val;
|
|
316
|
-
},
|
|
317
|
-
};
|
|
318
|
-
readIndex = async (tag) => {
|
|
319
|
-
const v = await kv.get('_tc:' + tag, 'text');
|
|
320
|
-
if (v === null) return new Set();
|
|
321
|
-
try { return new Set(JSON.parse(v)); } catch { return new Set(); }
|
|
322
|
-
};
|
|
323
|
-
writeIndex = async (tag, set) => {
|
|
324
|
-
if (set.size === 0) await kv.delete('_tc:' + tag);
|
|
325
|
-
else await kv.put('_tc:' + tag, JSON.stringify([...set]));
|
|
326
|
-
};
|
|
327
|
-
} else {
|
|
328
|
-
const mem = new Map(); // key -> { value, exp }
|
|
329
|
-
base = {
|
|
330
|
-
_mem: mem,
|
|
331
|
-
async get(key) {
|
|
332
|
-
const e = mem.get(key);
|
|
333
|
-
if (!e) return null;
|
|
334
|
-
if (e.exp !== null && Date.now() > e.exp) { mem.delete(key); return null; }
|
|
335
|
-
return e.value;
|
|
336
|
-
},
|
|
337
|
-
async put(key, value, ttl) {
|
|
338
|
-
const ms = ttl !== undefined ? ttl : ttlMs;
|
|
339
|
-
const exp = ms ? Date.now() + ms : null;
|
|
340
|
-
mem.set(key, { value, exp });
|
|
341
|
-
},
|
|
342
|
-
async forget(key) { mem.delete(key); },
|
|
343
|
-
async remember(key, ttl, cb) {
|
|
344
|
-
const hit = await this.get(key);
|
|
345
|
-
if (hit !== null) return hit;
|
|
346
|
-
const val = await cb();
|
|
347
|
-
await this.put(key, val, ttl);
|
|
348
|
-
return val;
|
|
349
|
-
},
|
|
350
|
-
};
|
|
351
|
-
const tagIndex = new Map();
|
|
352
|
-
readIndex = async (tag) => tagIndex.get(tag) || new Set();
|
|
353
|
-
writeIndex = async (tag, set) => {
|
|
354
|
-
if (set.size === 0) tagIndex.delete(tag);
|
|
355
|
-
else tagIndex.set(tag, new Set(set));
|
|
356
|
-
};
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
function makeView(tags) {
|
|
360
|
-
return {
|
|
361
|
-
async get(key) { return base.get(key); },
|
|
362
|
-
async put(key, value, ttl) {
|
|
363
|
-
await base.put(key, value, ttl);
|
|
364
|
-
for (const tag of tags) {
|
|
365
|
-
const idx = await readIndex(tag);
|
|
366
|
-
idx.add(key);
|
|
367
|
-
await writeIndex(tag, idx);
|
|
368
|
-
}
|
|
369
|
-
},
|
|
370
|
-
async forget(key) {
|
|
371
|
-
await base.forget(key);
|
|
372
|
-
for (const tag of tags) {
|
|
373
|
-
const idx = await readIndex(tag);
|
|
374
|
-
if (idx.delete(key)) await writeIndex(tag, idx);
|
|
375
|
-
}
|
|
376
|
-
},
|
|
377
|
-
async remember(key, ttl, cb) {
|
|
378
|
-
const hit = await base.get(key);
|
|
379
|
-
if (hit !== null) return hit;
|
|
380
|
-
const val = await cb();
|
|
381
|
-
await this.put(key, val, ttl);
|
|
382
|
-
return val;
|
|
383
|
-
},
|
|
384
|
-
async flush() {
|
|
385
|
-
const keys = new Set();
|
|
386
|
-
for (const tag of tags) {
|
|
387
|
-
const idx = await readIndex(tag);
|
|
388
|
-
for (const k of idx) keys.add(k);
|
|
389
|
-
}
|
|
390
|
-
for (const k of keys) await base.forget(k);
|
|
391
|
-
for (const tag of tags) await writeIndex(tag, new Set());
|
|
392
|
-
},
|
|
393
|
-
};
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
const cache = Object.assign({}, base, {
|
|
397
|
-
tags(names) {
|
|
398
|
-
const tags = normalizeTags(names);
|
|
399
|
-
return makeView(tags);
|
|
400
|
-
},
|
|
401
|
-
});
|
|
402
|
-
// preserve _mem/_store refs already in base spread
|
|
403
|
-
return cache;
|
|
404
|
-
}
|
|
405
|
-
export default { createCache, DatabaseCacheStore, createDatabaseCache };
|
|
1
|
+
// kernel/cache.mjs — Cache (KV or memory+TTL), with tags
|
|
2
|
+
//
|
|
3
|
+
// Also ports Laravel's `database` cache store (Illuminate\Cache\DatabaseStore,
|
|
4
|
+
// 13.37) to D1 — the driver the compiled rung uses for `throttle:n,m` and the
|
|
5
|
+
// `Cache` facade. Semantics mirror the vendor line-for-line:
|
|
6
|
+
// - keys are prefixed inside the store (Repository::itemKey is the identity);
|
|
7
|
+
// - values are `serialize()`d, and a NUL byte triggers base64 (the SQLite /
|
|
8
|
+
// Postgres rule — compare `Illuminate\Cache\DatabaseStore::serialize`);
|
|
9
|
+
// - expiration is an integer second timestamp; expired rows are read-then-deleted;
|
|
10
|
+
// - `add` is INSERT OR IGNORE, `put` is an upsert, `forever` is +315360000s.
|
|
11
|
+
// Known divergence: D1 has no interactive transactions, so increment/decrement
|
|
12
|
+
// are a read-then-write (Laravel wraps them in a transaction). Documented in COMPILED.md.
|
|
13
|
+
import { phpSerialize, phpUnserialize } from './php-serialize.mjs';
|
|
14
|
+
|
|
15
|
+
/** Illuminate\Cache\Repository::FLEXIBLE_CREATED_KEY_PREFIX */
|
|
16
|
+
export const FLEXIBLE_CREATED_KEY_PREFIX = 'illuminate:cache:flexible:created:';
|
|
17
|
+
const FOREVER_SECONDS = 315360000;
|
|
18
|
+
|
|
19
|
+
/** PHP is_numeric() for the value shapes a cache row can hold. */
|
|
20
|
+
function phpIsNumericValue(value) {
|
|
21
|
+
if (typeof value === 'number') return true;
|
|
22
|
+
if (typeof value === 'string') {
|
|
23
|
+
return /^[ \t\n\r\v\f]*[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?[ \t\n\r\v\f]*$/.test(value);
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** PHP (int) cast for the value shapes a cache row can hold. */
|
|
29
|
+
function phpIntCastValue(value) {
|
|
30
|
+
if (value === null || value === undefined || value === false) return 0;
|
|
31
|
+
if (value === true) return 1;
|
|
32
|
+
if (typeof value === 'number') return Number.isFinite(value) ? Math.trunc(value) : 0;
|
|
33
|
+
if (typeof value === 'string') {
|
|
34
|
+
const m = /^[ \t\n\r\v\f]*([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)/.exec(value);
|
|
35
|
+
if (!m) return 0;
|
|
36
|
+
const n = /[.eE]/.test(m[1]) ? parseFloat(m[1]) : parseInt(m[1], 10);
|
|
37
|
+
return Number.isFinite(n) ? Math.trunc(n) : 0;
|
|
38
|
+
}
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function phpValue(value) {
|
|
43
|
+
return typeof value === 'function' ? value() : value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// PHP base64_encode/decode over the byte string. phpSerialize emits raw bytes as
|
|
47
|
+
// one-code-unit-per-byte; mask to keep the base64 alphabet on the original bytes.
|
|
48
|
+
function base64EncodeBytes(str) {
|
|
49
|
+
let bin = '';
|
|
50
|
+
for (let i = 0; i < str.length; i++) bin += String.fromCharCode(str.charCodeAt(i) & 0xff);
|
|
51
|
+
return btoa(bin);
|
|
52
|
+
}
|
|
53
|
+
function base64DecodeBytes(b64) {
|
|
54
|
+
const bin = atob(String(b64));
|
|
55
|
+
return bin;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Illuminate\Cache\DatabaseStore (+ the Repository-level methods the kernel's
|
|
60
|
+
* RateLimiter calls: get/has/put/add/increment/decrement/forever/forget).
|
|
61
|
+
*
|
|
62
|
+
* @param {object} db D1 binding (prepare/bind/all/first/run)
|
|
63
|
+
* @param {object} [options]
|
|
64
|
+
* @param {string} [options.table] cache table (default "cache")
|
|
65
|
+
* @param {string} [options.prefix] cache key prefix (config cache.prefix)
|
|
66
|
+
* @param {string} [options.lockTable] cache_locks table
|
|
67
|
+
* @param {() => number} [options.clock] integer-second clock
|
|
68
|
+
*/
|
|
69
|
+
export class DatabaseCacheStore {
|
|
70
|
+
constructor(db, options = {}) {
|
|
71
|
+
if (!db || typeof db.prepare !== 'function') {
|
|
72
|
+
throw new Error('DatabaseCacheStore: a D1 binding is required');
|
|
73
|
+
}
|
|
74
|
+
this.db = db;
|
|
75
|
+
this.table = options.table || 'cache';
|
|
76
|
+
this.prefix = options.prefix || '';
|
|
77
|
+
this.lockTable = options.lockTable || 'cache_locks';
|
|
78
|
+
this._clock = typeof options.clock === 'function' ? options.clock : () => Math.floor(Date.now() / 1000);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
getPrefix() {
|
|
82
|
+
return this.prefix;
|
|
83
|
+
}
|
|
84
|
+
setPrefix(prefix) {
|
|
85
|
+
this.prefix = String(prefix);
|
|
86
|
+
return this;
|
|
87
|
+
}
|
|
88
|
+
/** InteractsWithTime::currentTime() */
|
|
89
|
+
currentTime() {
|
|
90
|
+
return this._clock();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** DatabaseStore::serialize() — base64 when the payload carries a NUL byte. */
|
|
94
|
+
_serialize(value) {
|
|
95
|
+
const result = phpSerialize(value);
|
|
96
|
+
return result.includes('\u0000') ? base64EncodeBytes(result) : result;
|
|
97
|
+
}
|
|
98
|
+
/** DatabaseStore::unserialize() — a value with no ":" / ";" is base64. */
|
|
99
|
+
_unserialize(value) {
|
|
100
|
+
let v = String(value);
|
|
101
|
+
if (!v.includes(':') && !v.includes(';')) v = base64DecodeBytes(v);
|
|
102
|
+
return phpUnserialize(v);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async many(keys) {
|
|
106
|
+
const out = {};
|
|
107
|
+
for (const k of keys) out[k] = null;
|
|
108
|
+
if (keys.length === 0) return out;
|
|
109
|
+
const prefixed = keys.map((k) => this.prefix + k);
|
|
110
|
+
const placeholders = prefixed.map(() => '?').join(', ');
|
|
111
|
+
const { results } = await this.db
|
|
112
|
+
.prepare(`SELECT "key", "value", "expiration" FROM ${this.table} WHERE "key" IN (${placeholders})`)
|
|
113
|
+
.bind(...prefixed)
|
|
114
|
+
.all();
|
|
115
|
+
const now = this.currentTime();
|
|
116
|
+
const live = new Map();
|
|
117
|
+
const expired = [];
|
|
118
|
+
for (const row of results || []) {
|
|
119
|
+
if (row.expiration > now) live.set(row.key, row);
|
|
120
|
+
else expired.push(row.key);
|
|
121
|
+
}
|
|
122
|
+
if (expired.length) await this._forgetManyIfExpired(expired);
|
|
123
|
+
for (const k of keys) {
|
|
124
|
+
const row = live.get(this.prefix + k);
|
|
125
|
+
if (row) out[k] = this._unserialize(row.value);
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Repository::get(key, default) */
|
|
131
|
+
async get(key, defaultValue = null) {
|
|
132
|
+
const value = (await this.many([key]))[key];
|
|
133
|
+
return value === null ? phpValue(defaultValue) : value;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Repository::has(key) — ! is_null(get(key)) */
|
|
137
|
+
async has(key) {
|
|
138
|
+
return (await this.get(key)) !== null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** DatabaseStore::putMany() — one upsert per row (D1 has no multi-row upsert). */
|
|
142
|
+
async putMany(values, seconds) {
|
|
143
|
+
// Laravel's Repository::put() (Cache/Repository.php:367-380): a NULL ttl
|
|
144
|
+
// means "forever" ($this->forever($key, $value)); a ttl of 0 (or less)
|
|
145
|
+
// FORGETS the key (`if ($seconds <= 0) { return $this->forget($key); }`).
|
|
146
|
+
// The edge stored a ttl of 0 for TEN YEARS (now + 315360000) - a key that
|
|
147
|
+
// Laravel drops was kept forever.
|
|
148
|
+
const ttl = seconds === undefined || seconds === null ? null : phpIntCastValue(seconds);
|
|
149
|
+
if (ttl !== null && ttl <= 0) {
|
|
150
|
+
let forgotten = 0;
|
|
151
|
+
for (const key of Object.keys(values)) {
|
|
152
|
+
forgotten += await this.forget(key) ? 1 : 0;
|
|
153
|
+
}
|
|
154
|
+
return forgotten > 0;
|
|
155
|
+
}
|
|
156
|
+
const expiration = ttl === null ? this.currentTime() + FOREVER_SECONDS : this.currentTime() + ttl;
|
|
157
|
+
let changes = 0;
|
|
158
|
+
for (const [key, value] of Object.entries(values)) {
|
|
159
|
+
const res = await this.db
|
|
160
|
+
.prepare(
|
|
161
|
+
`INSERT INTO ${this.table} ("key", "value", "expiration") VALUES (?, ?, ?)
|
|
162
|
+
ON CONFLICT("key") DO UPDATE SET "value" = excluded."value", "expiration" = excluded."expiration"`
|
|
163
|
+
)
|
|
164
|
+
.bind(this.prefix + key, this._serialize(value), expiration)
|
|
165
|
+
.run();
|
|
166
|
+
changes += Number((res && res.meta && res.meta.changes) || 0);
|
|
167
|
+
}
|
|
168
|
+
return changes > 0;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** DatabaseStore::put() */
|
|
172
|
+
async put(key, value, seconds) {
|
|
173
|
+
return this.putMany({ [key]: value }, seconds);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** DatabaseStore::add() — INSERT OR IGNORE, false when the key already lives. */
|
|
177
|
+
async add(key, value, seconds) {
|
|
178
|
+
if ((await this.get(key)) !== null) return false;
|
|
179
|
+
const res = await this.db
|
|
180
|
+
.prepare(`INSERT OR IGNORE INTO ${this.table} ("key", "value", "expiration") VALUES (?, ?, ?)`)
|
|
181
|
+
.bind(
|
|
182
|
+
this.prefix + key,
|
|
183
|
+
this._serialize(value),
|
|
184
|
+
phpIntCastValue(seconds) > 0 ? this.currentTime() + phpIntCastValue(seconds) : this.currentTime() + FOREVER_SECONDS
|
|
185
|
+
)
|
|
186
|
+
.run();
|
|
187
|
+
return Number((res && res.meta && res.meta.changes) || 0) > 0;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** DatabaseStore::forever() */
|
|
191
|
+
async forever(key, value) {
|
|
192
|
+
return this.put(key, value, FOREVER_SECONDS);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** DatabaseStore::increment() */
|
|
196
|
+
async increment(key, amount = 1) {
|
|
197
|
+
return this._incrementOrDecrement(key, amount, (current, by) => current + by);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** DatabaseStore::decrement() */
|
|
201
|
+
async decrement(key, amount = 1) {
|
|
202
|
+
return this._incrementOrDecrement(key, amount, (current, by) => current - by);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async _incrementOrDecrement(key, amount, callback) {
|
|
206
|
+
const prefixed = this.prefix + key;
|
|
207
|
+
const row = await this.db
|
|
208
|
+
.prepare(`SELECT "value" FROM ${this.table} WHERE "key" = ?`)
|
|
209
|
+
.bind(prefixed)
|
|
210
|
+
.first();
|
|
211
|
+
if (row === null || row === undefined) return false;
|
|
212
|
+
const current = this._unserialize(row.value);
|
|
213
|
+
const next = callback(phpIntCastValue(current), phpIntCastValue(amount));
|
|
214
|
+
if (!phpIsNumericValue(current)) return false;
|
|
215
|
+
await this.db
|
|
216
|
+
.prepare(`UPDATE ${this.table} SET "value" = ? WHERE "key" = ?`)
|
|
217
|
+
.bind(this._serialize(next), prefixed)
|
|
218
|
+
.run();
|
|
219
|
+
return next;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** DatabaseStore::remember() — get or compute+store. */
|
|
223
|
+
async remember(key, seconds, callback) {
|
|
224
|
+
const value = await this.get(key);
|
|
225
|
+
if (value !== null) return value;
|
|
226
|
+
const result = typeof callback === 'function' ? await callback() : callback;
|
|
227
|
+
await this.put(key, result, seconds);
|
|
228
|
+
return result;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Repository::pull() — get then forget. */
|
|
232
|
+
async pull(key, defaultValue = null) {
|
|
233
|
+
const value = await this.get(key);
|
|
234
|
+
await this.forget(key);
|
|
235
|
+
return value === null ? phpValue(defaultValue) : value;
|
|
236
|
+
}
|
|
237
|
+
/** DatabaseStore::forget() — also clears the flexible-created marker. */
|
|
238
|
+
async forget(key) {
|
|
239
|
+
const keys = [this.prefix + key, this.prefix + FLEXIBLE_CREATED_KEY_PREFIX + key];
|
|
240
|
+
await this.db
|
|
241
|
+
.prepare(`DELETE FROM ${this.table} WHERE "key" IN (?, ?)`)
|
|
242
|
+
.bind(...keys)
|
|
243
|
+
.run();
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async _forgetManyIfExpired(prefixedKeys) {
|
|
248
|
+
const keys = [];
|
|
249
|
+
for (const k of prefixedKeys) {
|
|
250
|
+
keys.push(k);
|
|
251
|
+
const bare = k.startsWith(this.prefix) ? k.slice(this.prefix.length) : k;
|
|
252
|
+
keys.push(this.prefix + FLEXIBLE_CREATED_KEY_PREFIX + bare);
|
|
253
|
+
}
|
|
254
|
+
const placeholders = keys.map(() => '?').join(', ');
|
|
255
|
+
await this.db
|
|
256
|
+
.prepare(`DELETE FROM ${this.table} WHERE "key" IN (${placeholders}) AND "expiration" <= ?`)
|
|
257
|
+
.bind(...keys, this.currentTime())
|
|
258
|
+
.run();
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** DatabaseStore::touch() */
|
|
263
|
+
async touch(key, seconds) {
|
|
264
|
+
const now = this.currentTime();
|
|
265
|
+
const res = await this.db
|
|
266
|
+
.prepare(`UPDATE ${this.table} SET "expiration" = ? WHERE "key" = ? AND "expiration" > ?`)
|
|
267
|
+
.bind(now + phpIntCastValue(seconds), this.prefix + key, now)
|
|
268
|
+
.run();
|
|
269
|
+
return Number((res && res.meta && res.meta.changes) || 0) > 0;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** DatabaseStore::flush() */
|
|
273
|
+
async flush() {
|
|
274
|
+
await this.db.prepare(`DELETE FROM ${this.table}`).run();
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Build the D1-backed cache store from the flat generated config
|
|
281
|
+
* (cache.driver / cache.table / cache.prefix / cache.lock_table).
|
|
282
|
+
*/
|
|
283
|
+
export function createDatabaseCache({ db, config = {}, clock } = {}) {
|
|
284
|
+
return new DatabaseCacheStore(db, {
|
|
285
|
+
table: config['cache.table'] || 'cache',
|
|
286
|
+
prefix: config['cache.prefix'] || '',
|
|
287
|
+
lockTable: config['cache.lock_table'] || 'cache_locks',
|
|
288
|
+
clock,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function normalizeTags(names) {
|
|
293
|
+
if (typeof names === 'string') names = [names];
|
|
294
|
+
if (!Array.isArray(names) || names.length === 0) throw new Error('tags requires a non-empty array');
|
|
295
|
+
return names.map(n => String(n));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function createCache({ kv, ttlMs = 60000, secret } = {}) {
|
|
299
|
+
let base;
|
|
300
|
+
let readIndex, writeIndex;
|
|
301
|
+
|
|
302
|
+
if (kv && typeof kv.get === 'function') {
|
|
303
|
+
base = {
|
|
304
|
+
async get(key) { const v = await kv.get(key, 'text'); return v === null ? null : JSON.parse(v); },
|
|
305
|
+
async put(key, value, ttl) {
|
|
306
|
+
const opts = ttl ? { expirationTtl: Math.ceil(ttl / 1000) } : undefined;
|
|
307
|
+
await kv.put(key, JSON.stringify(value), opts);
|
|
308
|
+
},
|
|
309
|
+
async forget(key) { await kv.delete(key); },
|
|
310
|
+
async remember(key, ttl, cb) {
|
|
311
|
+
const hit = await kv.get(key, 'text');
|
|
312
|
+
if (hit !== null) return JSON.parse(hit);
|
|
313
|
+
const val = await cb();
|
|
314
|
+
await kv.put(key, JSON.stringify(val), { expirationTtl: Math.ceil(ttl / 1000) });
|
|
315
|
+
return val;
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
readIndex = async (tag) => {
|
|
319
|
+
const v = await kv.get('_tc:' + tag, 'text');
|
|
320
|
+
if (v === null) return new Set();
|
|
321
|
+
try { return new Set(JSON.parse(v)); } catch { return new Set(); }
|
|
322
|
+
};
|
|
323
|
+
writeIndex = async (tag, set) => {
|
|
324
|
+
if (set.size === 0) await kv.delete('_tc:' + tag);
|
|
325
|
+
else await kv.put('_tc:' + tag, JSON.stringify([...set]));
|
|
326
|
+
};
|
|
327
|
+
} else {
|
|
328
|
+
const mem = new Map(); // key -> { value, exp }
|
|
329
|
+
base = {
|
|
330
|
+
_mem: mem,
|
|
331
|
+
async get(key) {
|
|
332
|
+
const e = mem.get(key);
|
|
333
|
+
if (!e) return null;
|
|
334
|
+
if (e.exp !== null && Date.now() > e.exp) { mem.delete(key); return null; }
|
|
335
|
+
return e.value;
|
|
336
|
+
},
|
|
337
|
+
async put(key, value, ttl) {
|
|
338
|
+
const ms = ttl !== undefined ? ttl : ttlMs;
|
|
339
|
+
const exp = ms ? Date.now() + ms : null;
|
|
340
|
+
mem.set(key, { value, exp });
|
|
341
|
+
},
|
|
342
|
+
async forget(key) { mem.delete(key); },
|
|
343
|
+
async remember(key, ttl, cb) {
|
|
344
|
+
const hit = await this.get(key);
|
|
345
|
+
if (hit !== null) return hit;
|
|
346
|
+
const val = await cb();
|
|
347
|
+
await this.put(key, val, ttl);
|
|
348
|
+
return val;
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
const tagIndex = new Map();
|
|
352
|
+
readIndex = async (tag) => tagIndex.get(tag) || new Set();
|
|
353
|
+
writeIndex = async (tag, set) => {
|
|
354
|
+
if (set.size === 0) tagIndex.delete(tag);
|
|
355
|
+
else tagIndex.set(tag, new Set(set));
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function makeView(tags) {
|
|
360
|
+
return {
|
|
361
|
+
async get(key) { return base.get(key); },
|
|
362
|
+
async put(key, value, ttl) {
|
|
363
|
+
await base.put(key, value, ttl);
|
|
364
|
+
for (const tag of tags) {
|
|
365
|
+
const idx = await readIndex(tag);
|
|
366
|
+
idx.add(key);
|
|
367
|
+
await writeIndex(tag, idx);
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
async forget(key) {
|
|
371
|
+
await base.forget(key);
|
|
372
|
+
for (const tag of tags) {
|
|
373
|
+
const idx = await readIndex(tag);
|
|
374
|
+
if (idx.delete(key)) await writeIndex(tag, idx);
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
async remember(key, ttl, cb) {
|
|
378
|
+
const hit = await base.get(key);
|
|
379
|
+
if (hit !== null) return hit;
|
|
380
|
+
const val = await cb();
|
|
381
|
+
await this.put(key, val, ttl);
|
|
382
|
+
return val;
|
|
383
|
+
},
|
|
384
|
+
async flush() {
|
|
385
|
+
const keys = new Set();
|
|
386
|
+
for (const tag of tags) {
|
|
387
|
+
const idx = await readIndex(tag);
|
|
388
|
+
for (const k of idx) keys.add(k);
|
|
389
|
+
}
|
|
390
|
+
for (const k of keys) await base.forget(k);
|
|
391
|
+
for (const tag of tags) await writeIndex(tag, new Set());
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const cache = Object.assign({}, base, {
|
|
397
|
+
tags(names) {
|
|
398
|
+
const tags = normalizeTags(names);
|
|
399
|
+
return makeView(tags);
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
// preserve _mem/_store refs already in base spread
|
|
403
|
+
return cache;
|
|
404
|
+
}
|
|
405
|
+
export default { createCache, DatabaseCacheStore, createDatabaseCache };
|