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
|
@@ -1,290 +1,290 @@
|
|
|
1
|
-
// kernel/tests/cache.test.mjs — D1-backed `database` cache store parity vs PHP 8.3.
|
|
2
|
-
//
|
|
3
|
-
// Drives the identical tagged op sequence through:
|
|
4
|
-
// - tools/php-cache-oracle.php (Illuminate\Cache\DatabaseStore on SQLite), and
|
|
5
|
-
// - DatabaseCacheStore (kernel/cache.mjs on a D1-shaped node:sqlite),
|
|
6
|
-
// and asserts both the per-op return values and the raw `cache` rows match 1:1.
|
|
7
|
-
//
|
|
8
|
-
// Covers the methods the compiled rung's RateLimiter + Cache facade call:
|
|
9
|
-
// get / has / put / add / forever / increment / decrement / forget / touch / flush,
|
|
10
|
-
// plus expiration (read-then-delete) and the SQLite NUL -> base64 serialization rule.
|
|
11
|
-
import { test } from 'node:test';
|
|
12
|
-
import assert from 'node:assert/strict';
|
|
13
|
-
import { spawnSync } from 'node:child_process';
|
|
14
|
-
import { existsSync } from 'node:fs';
|
|
15
|
-
import path from 'node:path';
|
|
16
|
-
import { DatabaseSync } from 'node:sqlite';
|
|
17
|
-
import { fileURLToPath } from 'node:url';
|
|
18
|
-
import { DatabaseCacheStore } from '../cache.mjs';
|
|
19
|
-
import { oracleAppRoot, oracleSkip } from './oracle-app.mjs';
|
|
20
|
-
|
|
21
|
-
const testsDir = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
-
const oraclePath = path.resolve(testsDir, '..', '..', 'tools', 'php-cache-oracle.php');
|
|
23
|
-
const PHP = process.env.PHP_BIN || 'php';
|
|
24
|
-
const APP_ROOT = oracleAppRoot();
|
|
25
|
-
// The PHP oracle parity tests need a real Laravel app (PHP 8.3 + vendor/).
|
|
26
|
-
// Without one they SKIP (the CI `test` job has no app; the `e2e` job points
|
|
27
|
-
// LW_LARAVEL_APP at the app it creates and runs them for real).
|
|
28
|
-
const noApp = APP_ROOT ? false : oracleSkip('cache.test.mjs');
|
|
29
|
-
|
|
30
|
-
const PREFIX = 'laravel-cache-';
|
|
31
|
-
const NOW = 1792336827; // Sun, 18 Oct 2026 15:20:27 GMT
|
|
32
|
-
|
|
33
|
-
/** PHP value -> tagged JSON */
|
|
34
|
-
function enc(value) {
|
|
35
|
-
if (value === null) return { t: 'null' };
|
|
36
|
-
if (typeof value === 'boolean') return { t: 'bool', v: value };
|
|
37
|
-
if (typeof value === 'number') return Number.isInteger(value) ? { t: 'int', v: value } : { t: 'float', v: value };
|
|
38
|
-
if (typeof value === 'string') return { t: 'str', v: value };
|
|
39
|
-
if (Array.isArray(value)) return { t: 'array', v: value.map(enc) };
|
|
40
|
-
const out = {};
|
|
41
|
-
for (const k of Object.keys(value)) out[k] = enc(value[k]);
|
|
42
|
-
return { t: 'array', v: out };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** tagged JSON -> PHP value */
|
|
46
|
-
function dec(tagged) {
|
|
47
|
-
if (tagged === null) return null;
|
|
48
|
-
switch (tagged.t) {
|
|
49
|
-
case 'int':
|
|
50
|
-
case 'float':
|
|
51
|
-
return Number(tagged.v);
|
|
52
|
-
case 'str':
|
|
53
|
-
return String(tagged.v);
|
|
54
|
-
case 'bool':
|
|
55
|
-
return Boolean(tagged.v);
|
|
56
|
-
case 'null':
|
|
57
|
-
return null;
|
|
58
|
-
case 'array': {
|
|
59
|
-
const src = tagged.v;
|
|
60
|
-
if (Array.isArray(src)) return src.map(dec);
|
|
61
|
-
const out = {};
|
|
62
|
-
for (const k of Object.keys(src)) out[k] = dec(src[k]);
|
|
63
|
-
return out;
|
|
64
|
-
}
|
|
65
|
-
default:
|
|
66
|
-
throw new Error('unknown tag ' + tagged.t);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function taggedEqual(a, b, msg) {
|
|
71
|
-
assert.deepEqual(a, b, msg);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Minimal D1-shaped wrapper over node:sqlite.
|
|
75
|
-
function d1(sqlite) {
|
|
76
|
-
return {
|
|
77
|
-
prepare(sql) {
|
|
78
|
-
let params = [];
|
|
79
|
-
return {
|
|
80
|
-
bind(...p) {
|
|
81
|
-
params = p;
|
|
82
|
-
return this;
|
|
83
|
-
},
|
|
84
|
-
async all() {
|
|
85
|
-
const st = sqlite.prepare(sql);
|
|
86
|
-
const rows = params.length ? st.all(...params) : st.all();
|
|
87
|
-
return { results: rows.map((r) => ({ ...r })) };
|
|
88
|
-
},
|
|
89
|
-
async first() {
|
|
90
|
-
const st = sqlite.prepare(sql);
|
|
91
|
-
const row = params.length ? st.get(...params) : st.get();
|
|
92
|
-
return row ? { ...row } : null;
|
|
93
|
-
},
|
|
94
|
-
async run() {
|
|
95
|
-
const st = sqlite.prepare(sql);
|
|
96
|
-
const info = params.length ? st.run(...params) : st.run();
|
|
97
|
-
return { success: true, meta: { changes: Number(info.changes), last_row_id: Number(info.lastInsertRowid ?? 0) } };
|
|
98
|
-
},
|
|
99
|
-
};
|
|
100
|
-
},
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function makeSqlite() {
|
|
105
|
-
const sqlite = new DatabaseSync(':memory:');
|
|
106
|
-
sqlite.exec(`
|
|
107
|
-
CREATE TABLE cache (
|
|
108
|
-
"key" TEXT PRIMARY KEY,
|
|
109
|
-
"value" TEXT NOT NULL,
|
|
110
|
-
"expiration" INTEGER NOT NULL
|
|
111
|
-
);
|
|
112
|
-
CREATE TABLE cache_locks (
|
|
113
|
-
"key" TEXT PRIMARY KEY,
|
|
114
|
-
"owner" TEXT NOT NULL,
|
|
115
|
-
"expiration" INTEGER NOT NULL
|
|
116
|
-
);
|
|
117
|
-
`);
|
|
118
|
-
return sqlite;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function readRows(sqlite) {
|
|
122
|
-
return sqlite.prepare('SELECT "key", "value", "expiration" FROM cache ORDER BY "key"').all().map((r) => ({
|
|
123
|
-
key: r.key,
|
|
124
|
-
value: r.value,
|
|
125
|
-
expiration: Number(r.expiration),
|
|
126
|
-
}));
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// The identical sequence both sides run.
|
|
130
|
-
const OPS = [
|
|
131
|
-
{ op: 'put', key: 'alpha', value: enc('plain string'), seconds: 3600 },
|
|
132
|
-
{ op: 'put', key: 'num', value: enc(42), seconds: 3600 },
|
|
133
|
-
{ op: 'put', key: 'flt', value: enc(1.5), seconds: 3600 },
|
|
134
|
-
{ op: 'put', key: 'arr', value: enc({ a: 1, b: { c: [1, 2, 3] } }), seconds: 3600 },
|
|
135
|
-
{ op: 'put', key: 'nul', value: enc('x\u0000y'), seconds: 3600 },
|
|
136
|
-
{ op: 'forever', key: 'ever', value: enc('kept') },
|
|
137
|
-
{ op: 'add', key: 'alpha', value: enc('other'), seconds: 3600 }, // false (exists)
|
|
138
|
-
{ op: 'add', key: 'fresh', value: enc('v'), seconds: 3600 }, // true
|
|
139
|
-
{ op: 'get', key: 'alpha' },
|
|
140
|
-
{ op: 'get', key: 'num' },
|
|
141
|
-
{ op: 'get', key: 'flt' },
|
|
142
|
-
{ op: 'get', key: 'arr' },
|
|
143
|
-
{ op: 'get', key: 'nul' },
|
|
144
|
-
{ op: 'get', key: 'missing' },
|
|
145
|
-
{ op: 'get', key: 'missing', default: enc('DEF') },
|
|
146
|
-
{ op: 'has', key: 'num' },
|
|
147
|
-
{ op: 'has', key: 'missing' },
|
|
148
|
-
{ op: 'increment', key: 'num', amount: 8 }, // 50
|
|
149
|
-
{ op: 'decrement', key: 'num', amount: 3 }, // 47
|
|
150
|
-
{ op: 'increment', key: 'nope' }, // false (missing)
|
|
151
|
-
{ op: 'put', key: 'short', value: enc('v'), seconds: 10 },
|
|
152
|
-
{ op: 'advance', seconds: 11 },
|
|
153
|
-
{ op: 'get', key: 'short' }, // null + row deleted
|
|
154
|
-
{ op: 'touch', key: 'ever', seconds: 100 },
|
|
155
|
-
{ op: 'forget', key: 'fresh' },
|
|
156
|
-
{ op: 'rows' },
|
|
157
|
-
{ op: 'flush' },
|
|
158
|
-
{ op: 'put', key: 'after', value: enc('post-flush'), seconds: 60 },
|
|
159
|
-
{ op: 'rows' },
|
|
160
|
-
];
|
|
161
|
-
|
|
162
|
-
async function runJs() {
|
|
163
|
-
const sqlite = makeSqlite();
|
|
164
|
-
let now = NOW;
|
|
165
|
-
const store = new DatabaseCacheStore(d1(sqlite), {
|
|
166
|
-
table: 'cache',
|
|
167
|
-
prefix: PREFIX,
|
|
168
|
-
lockTable: 'cache_locks',
|
|
169
|
-
clock: () => now,
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
const results = [];
|
|
173
|
-
for (const op of OPS) {
|
|
174
|
-
switch (op.op) {
|
|
175
|
-
case 'put':
|
|
176
|
-
results.push(enc(await store.put(op.key, dec(op.value), op.seconds)));
|
|
177
|
-
break;
|
|
178
|
-
case 'add':
|
|
179
|
-
results.push(enc(await store.add(op.key, dec(op.value), op.seconds)));
|
|
180
|
-
break;
|
|
181
|
-
case 'forever':
|
|
182
|
-
results.push(enc(await store.forever(op.key, dec(op.value))));
|
|
183
|
-
break;
|
|
184
|
-
case 'get':
|
|
185
|
-
results.push(enc(await store.get(op.key, 'default' in op ? dec(op.default) : null)));
|
|
186
|
-
break;
|
|
187
|
-
case 'has':
|
|
188
|
-
results.push(enc(await store.has(op.key)));
|
|
189
|
-
break;
|
|
190
|
-
case 'increment':
|
|
191
|
-
results.push(enc(await store.increment(op.key, op.amount ?? 1)));
|
|
192
|
-
break;
|
|
193
|
-
case 'decrement':
|
|
194
|
-
results.push(enc(await store.decrement(op.key, op.amount ?? 1)));
|
|
195
|
-
break;
|
|
196
|
-
case 'forget':
|
|
197
|
-
results.push(enc(await store.forget(op.key)));
|
|
198
|
-
break;
|
|
199
|
-
case 'touch':
|
|
200
|
-
results.push(enc(await store.touch(op.key, op.seconds)));
|
|
201
|
-
break;
|
|
202
|
-
case 'flush':
|
|
203
|
-
results.push(enc(await store.flush()));
|
|
204
|
-
break;
|
|
205
|
-
case 'advance':
|
|
206
|
-
now += Number(op.seconds);
|
|
207
|
-
results.push(enc(now));
|
|
208
|
-
break;
|
|
209
|
-
case 'rows':
|
|
210
|
-
results.push(enc(readRows(sqlite)));
|
|
211
|
-
break;
|
|
212
|
-
default:
|
|
213
|
-
throw new Error('unknown op ' + op.op);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
return { results, rows: readRows(sqlite) };
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function runOracle() {
|
|
220
|
-
if (!APP_ROOT || !existsSync(path.join(APP_ROOT, 'vendor', 'autoload.php'))) {
|
|
221
|
-
throw new Error(oracleSkip('cache.test.mjs'));
|
|
222
|
-
}
|
|
223
|
-
const res = spawnSync(PHP, [oraclePath], {
|
|
224
|
-
input: JSON.stringify({ app_root: APP_ROOT, prefix: PREFIX, now: NOW, ops: OPS }),
|
|
225
|
-
encoding: 'utf8',
|
|
226
|
-
maxBuffer: 64 * 1024 * 1024,
|
|
227
|
-
});
|
|
228
|
-
if (res.error) {
|
|
229
|
-
throw new Error(`PHP oracle failed to start (${res.error.message}). Install PHP 8.3 or set PHP_BIN.`);
|
|
230
|
-
}
|
|
231
|
-
if (res.status !== 0) {
|
|
232
|
-
throw new Error(`PHP oracle exited ${res.status}: ${res.stderr}`);
|
|
233
|
-
}
|
|
234
|
-
return JSON.parse(res.stdout);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
test('DatabaseCacheStore: op results + raw cache rows match PHP 8.3 1:1', { skip: noApp }, async () => {
|
|
238
|
-
const js = await runJs();
|
|
239
|
-
const php = runOracle();
|
|
240
|
-
assert.equal(js.results.length, php.results.length, 'op count');
|
|
241
|
-
for (let i = 0; i < js.results.length; i++) {
|
|
242
|
-
taggedEqual(js.results[i], php.results[i], `op #${i} ${OPS[i].op} ${OPS[i].key || ''}`);
|
|
243
|
-
}
|
|
244
|
-
assert.deepEqual(js.rows, php.rows, 'final cache rows');
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
test('DatabaseCacheStore: get(key, default) returns the default on miss', async () => {
|
|
248
|
-
const sqlite = makeSqlite();
|
|
249
|
-
const store = new DatabaseCacheStore(d1(sqlite), { table: 'cache', prefix: PREFIX, clock: () => NOW });
|
|
250
|
-
assert.equal(await store.get('nope'), null);
|
|
251
|
-
assert.equal(await store.get('nope', 7), 7);
|
|
252
|
-
assert.equal(await store.get('nope', 'x'), 'x');
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
test('DatabaseCacheStore: requires a D1 binding', () => {
|
|
256
|
-
assert.throws(() => new DatabaseCacheStore(null), /D1 binding/);
|
|
257
|
-
assert.throws(() => new DatabaseCacheStore({}), /D1 binding/);
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
test('DatabaseCacheStore: increment clamps PHP int cast (string current)', async () => {
|
|
261
|
-
const sqlite = makeSqlite();
|
|
262
|
-
const store = new DatabaseCacheStore(d1(sqlite), { table: 'cache', prefix: PREFIX, clock: () => NOW });
|
|
263
|
-
await store.put('s', 'not numeric', 60);
|
|
264
|
-
assert.equal(await store.increment('s', 1), false);
|
|
265
|
-
assert.equal(await store.get('s'), 'not numeric');
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
test('DatabaseCacheStore: prefix is applied inside the store', async () => {
|
|
269
|
-
const sqlite = makeSqlite();
|
|
270
|
-
const store = new DatabaseCacheStore(d1(sqlite), { table: 'cache', prefix: PREFIX, clock: () => NOW });
|
|
271
|
-
await store.put('k', 'v', 60);
|
|
272
|
-
const rows = readRows(sqlite);
|
|
273
|
-
assert.equal(rows.length, 1);
|
|
274
|
-
assert.equal(rows[0].key, PREFIX + 'k');
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
test('a TTL of 0 forgets the key (Laravel: Repository::put)', async () => {
|
|
278
|
-
// Laravel's Repository::put() (Cache/Repository.php:367-380): a NULL ttl
|
|
279
|
-
// means "forever"; a ttl of 0 (or less) FORGETS the key
|
|
280
|
-
// (`if ($seconds <= 0) { return $this->forget($key); }`). The edge stored it
|
|
281
|
-
// for ten years (now + 315360000).
|
|
282
|
-
const sqlite = makeSqlite();
|
|
283
|
-
const store = new DatabaseCacheStore(d1(sqlite), { table: 'cache', prefix: PREFIX, clock: () => NOW });
|
|
284
|
-
await store.put('k', 'v', 60);
|
|
285
|
-
assert.notEqual(await store.get('k'), null, 'the key is stored');
|
|
286
|
-
await store.put('k', 'v', 0);
|
|
287
|
-
assert.equal(await store.get('k'), null, 'a ttl of 0 forgets the key');
|
|
288
|
-
await store.put('k2', 'v');
|
|
289
|
-
assert.notEqual(await store.get('k2'), null, 'no ttl stores the key (forever)');
|
|
290
|
-
});
|
|
1
|
+
// kernel/tests/cache.test.mjs — D1-backed `database` cache store parity vs PHP 8.3.
|
|
2
|
+
//
|
|
3
|
+
// Drives the identical tagged op sequence through:
|
|
4
|
+
// - tools/php-cache-oracle.php (Illuminate\Cache\DatabaseStore on SQLite), and
|
|
5
|
+
// - DatabaseCacheStore (kernel/cache.mjs on a D1-shaped node:sqlite),
|
|
6
|
+
// and asserts both the per-op return values and the raw `cache` rows match 1:1.
|
|
7
|
+
//
|
|
8
|
+
// Covers the methods the compiled rung's RateLimiter + Cache facade call:
|
|
9
|
+
// get / has / put / add / forever / increment / decrement / forget / touch / flush,
|
|
10
|
+
// plus expiration (read-then-delete) and the SQLite NUL -> base64 serialization rule.
|
|
11
|
+
import { test } from 'node:test';
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { existsSync } from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { DatabaseCacheStore } from '../cache.mjs';
|
|
19
|
+
import { oracleAppRoot, oracleSkip } from './oracle-app.mjs';
|
|
20
|
+
|
|
21
|
+
const testsDir = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const oraclePath = path.resolve(testsDir, '..', '..', 'tools', 'php-cache-oracle.php');
|
|
23
|
+
const PHP = process.env.PHP_BIN || 'php';
|
|
24
|
+
const APP_ROOT = oracleAppRoot();
|
|
25
|
+
// The PHP oracle parity tests need a real Laravel app (PHP 8.3 + vendor/).
|
|
26
|
+
// Without one they SKIP (the CI `test` job has no app; the `e2e` job points
|
|
27
|
+
// LW_LARAVEL_APP at the app it creates and runs them for real).
|
|
28
|
+
const noApp = APP_ROOT ? false : oracleSkip('cache.test.mjs');
|
|
29
|
+
|
|
30
|
+
const PREFIX = 'laravel-cache-';
|
|
31
|
+
const NOW = 1792336827; // Sun, 18 Oct 2026 15:20:27 GMT
|
|
32
|
+
|
|
33
|
+
/** PHP value -> tagged JSON */
|
|
34
|
+
function enc(value) {
|
|
35
|
+
if (value === null) return { t: 'null' };
|
|
36
|
+
if (typeof value === 'boolean') return { t: 'bool', v: value };
|
|
37
|
+
if (typeof value === 'number') return Number.isInteger(value) ? { t: 'int', v: value } : { t: 'float', v: value };
|
|
38
|
+
if (typeof value === 'string') return { t: 'str', v: value };
|
|
39
|
+
if (Array.isArray(value)) return { t: 'array', v: value.map(enc) };
|
|
40
|
+
const out = {};
|
|
41
|
+
for (const k of Object.keys(value)) out[k] = enc(value[k]);
|
|
42
|
+
return { t: 'array', v: out };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** tagged JSON -> PHP value */
|
|
46
|
+
function dec(tagged) {
|
|
47
|
+
if (tagged === null) return null;
|
|
48
|
+
switch (tagged.t) {
|
|
49
|
+
case 'int':
|
|
50
|
+
case 'float':
|
|
51
|
+
return Number(tagged.v);
|
|
52
|
+
case 'str':
|
|
53
|
+
return String(tagged.v);
|
|
54
|
+
case 'bool':
|
|
55
|
+
return Boolean(tagged.v);
|
|
56
|
+
case 'null':
|
|
57
|
+
return null;
|
|
58
|
+
case 'array': {
|
|
59
|
+
const src = tagged.v;
|
|
60
|
+
if (Array.isArray(src)) return src.map(dec);
|
|
61
|
+
const out = {};
|
|
62
|
+
for (const k of Object.keys(src)) out[k] = dec(src[k]);
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
default:
|
|
66
|
+
throw new Error('unknown tag ' + tagged.t);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function taggedEqual(a, b, msg) {
|
|
71
|
+
assert.deepEqual(a, b, msg);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Minimal D1-shaped wrapper over node:sqlite.
|
|
75
|
+
function d1(sqlite) {
|
|
76
|
+
return {
|
|
77
|
+
prepare(sql) {
|
|
78
|
+
let params = [];
|
|
79
|
+
return {
|
|
80
|
+
bind(...p) {
|
|
81
|
+
params = p;
|
|
82
|
+
return this;
|
|
83
|
+
},
|
|
84
|
+
async all() {
|
|
85
|
+
const st = sqlite.prepare(sql);
|
|
86
|
+
const rows = params.length ? st.all(...params) : st.all();
|
|
87
|
+
return { results: rows.map((r) => ({ ...r })) };
|
|
88
|
+
},
|
|
89
|
+
async first() {
|
|
90
|
+
const st = sqlite.prepare(sql);
|
|
91
|
+
const row = params.length ? st.get(...params) : st.get();
|
|
92
|
+
return row ? { ...row } : null;
|
|
93
|
+
},
|
|
94
|
+
async run() {
|
|
95
|
+
const st = sqlite.prepare(sql);
|
|
96
|
+
const info = params.length ? st.run(...params) : st.run();
|
|
97
|
+
return { success: true, meta: { changes: Number(info.changes), last_row_id: Number(info.lastInsertRowid ?? 0) } };
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function makeSqlite() {
|
|
105
|
+
const sqlite = new DatabaseSync(':memory:');
|
|
106
|
+
sqlite.exec(`
|
|
107
|
+
CREATE TABLE cache (
|
|
108
|
+
"key" TEXT PRIMARY KEY,
|
|
109
|
+
"value" TEXT NOT NULL,
|
|
110
|
+
"expiration" INTEGER NOT NULL
|
|
111
|
+
);
|
|
112
|
+
CREATE TABLE cache_locks (
|
|
113
|
+
"key" TEXT PRIMARY KEY,
|
|
114
|
+
"owner" TEXT NOT NULL,
|
|
115
|
+
"expiration" INTEGER NOT NULL
|
|
116
|
+
);
|
|
117
|
+
`);
|
|
118
|
+
return sqlite;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function readRows(sqlite) {
|
|
122
|
+
return sqlite.prepare('SELECT "key", "value", "expiration" FROM cache ORDER BY "key"').all().map((r) => ({
|
|
123
|
+
key: r.key,
|
|
124
|
+
value: r.value,
|
|
125
|
+
expiration: Number(r.expiration),
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The identical sequence both sides run.
|
|
130
|
+
const OPS = [
|
|
131
|
+
{ op: 'put', key: 'alpha', value: enc('plain string'), seconds: 3600 },
|
|
132
|
+
{ op: 'put', key: 'num', value: enc(42), seconds: 3600 },
|
|
133
|
+
{ op: 'put', key: 'flt', value: enc(1.5), seconds: 3600 },
|
|
134
|
+
{ op: 'put', key: 'arr', value: enc({ a: 1, b: { c: [1, 2, 3] } }), seconds: 3600 },
|
|
135
|
+
{ op: 'put', key: 'nul', value: enc('x\u0000y'), seconds: 3600 },
|
|
136
|
+
{ op: 'forever', key: 'ever', value: enc('kept') },
|
|
137
|
+
{ op: 'add', key: 'alpha', value: enc('other'), seconds: 3600 }, // false (exists)
|
|
138
|
+
{ op: 'add', key: 'fresh', value: enc('v'), seconds: 3600 }, // true
|
|
139
|
+
{ op: 'get', key: 'alpha' },
|
|
140
|
+
{ op: 'get', key: 'num' },
|
|
141
|
+
{ op: 'get', key: 'flt' },
|
|
142
|
+
{ op: 'get', key: 'arr' },
|
|
143
|
+
{ op: 'get', key: 'nul' },
|
|
144
|
+
{ op: 'get', key: 'missing' },
|
|
145
|
+
{ op: 'get', key: 'missing', default: enc('DEF') },
|
|
146
|
+
{ op: 'has', key: 'num' },
|
|
147
|
+
{ op: 'has', key: 'missing' },
|
|
148
|
+
{ op: 'increment', key: 'num', amount: 8 }, // 50
|
|
149
|
+
{ op: 'decrement', key: 'num', amount: 3 }, // 47
|
|
150
|
+
{ op: 'increment', key: 'nope' }, // false (missing)
|
|
151
|
+
{ op: 'put', key: 'short', value: enc('v'), seconds: 10 },
|
|
152
|
+
{ op: 'advance', seconds: 11 },
|
|
153
|
+
{ op: 'get', key: 'short' }, // null + row deleted
|
|
154
|
+
{ op: 'touch', key: 'ever', seconds: 100 },
|
|
155
|
+
{ op: 'forget', key: 'fresh' },
|
|
156
|
+
{ op: 'rows' },
|
|
157
|
+
{ op: 'flush' },
|
|
158
|
+
{ op: 'put', key: 'after', value: enc('post-flush'), seconds: 60 },
|
|
159
|
+
{ op: 'rows' },
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
async function runJs() {
|
|
163
|
+
const sqlite = makeSqlite();
|
|
164
|
+
let now = NOW;
|
|
165
|
+
const store = new DatabaseCacheStore(d1(sqlite), {
|
|
166
|
+
table: 'cache',
|
|
167
|
+
prefix: PREFIX,
|
|
168
|
+
lockTable: 'cache_locks',
|
|
169
|
+
clock: () => now,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const results = [];
|
|
173
|
+
for (const op of OPS) {
|
|
174
|
+
switch (op.op) {
|
|
175
|
+
case 'put':
|
|
176
|
+
results.push(enc(await store.put(op.key, dec(op.value), op.seconds)));
|
|
177
|
+
break;
|
|
178
|
+
case 'add':
|
|
179
|
+
results.push(enc(await store.add(op.key, dec(op.value), op.seconds)));
|
|
180
|
+
break;
|
|
181
|
+
case 'forever':
|
|
182
|
+
results.push(enc(await store.forever(op.key, dec(op.value))));
|
|
183
|
+
break;
|
|
184
|
+
case 'get':
|
|
185
|
+
results.push(enc(await store.get(op.key, 'default' in op ? dec(op.default) : null)));
|
|
186
|
+
break;
|
|
187
|
+
case 'has':
|
|
188
|
+
results.push(enc(await store.has(op.key)));
|
|
189
|
+
break;
|
|
190
|
+
case 'increment':
|
|
191
|
+
results.push(enc(await store.increment(op.key, op.amount ?? 1)));
|
|
192
|
+
break;
|
|
193
|
+
case 'decrement':
|
|
194
|
+
results.push(enc(await store.decrement(op.key, op.amount ?? 1)));
|
|
195
|
+
break;
|
|
196
|
+
case 'forget':
|
|
197
|
+
results.push(enc(await store.forget(op.key)));
|
|
198
|
+
break;
|
|
199
|
+
case 'touch':
|
|
200
|
+
results.push(enc(await store.touch(op.key, op.seconds)));
|
|
201
|
+
break;
|
|
202
|
+
case 'flush':
|
|
203
|
+
results.push(enc(await store.flush()));
|
|
204
|
+
break;
|
|
205
|
+
case 'advance':
|
|
206
|
+
now += Number(op.seconds);
|
|
207
|
+
results.push(enc(now));
|
|
208
|
+
break;
|
|
209
|
+
case 'rows':
|
|
210
|
+
results.push(enc(readRows(sqlite)));
|
|
211
|
+
break;
|
|
212
|
+
default:
|
|
213
|
+
throw new Error('unknown op ' + op.op);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return { results, rows: readRows(sqlite) };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function runOracle() {
|
|
220
|
+
if (!APP_ROOT || !existsSync(path.join(APP_ROOT, 'vendor', 'autoload.php'))) {
|
|
221
|
+
throw new Error(oracleSkip('cache.test.mjs'));
|
|
222
|
+
}
|
|
223
|
+
const res = spawnSync(PHP, [oraclePath], {
|
|
224
|
+
input: JSON.stringify({ app_root: APP_ROOT, prefix: PREFIX, now: NOW, ops: OPS }),
|
|
225
|
+
encoding: 'utf8',
|
|
226
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
227
|
+
});
|
|
228
|
+
if (res.error) {
|
|
229
|
+
throw new Error(`PHP oracle failed to start (${res.error.message}). Install PHP 8.3 or set PHP_BIN.`);
|
|
230
|
+
}
|
|
231
|
+
if (res.status !== 0) {
|
|
232
|
+
throw new Error(`PHP oracle exited ${res.status}: ${res.stderr}`);
|
|
233
|
+
}
|
|
234
|
+
return JSON.parse(res.stdout);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
test('DatabaseCacheStore: op results + raw cache rows match PHP 8.3 1:1', { skip: noApp }, async () => {
|
|
238
|
+
const js = await runJs();
|
|
239
|
+
const php = runOracle();
|
|
240
|
+
assert.equal(js.results.length, php.results.length, 'op count');
|
|
241
|
+
for (let i = 0; i < js.results.length; i++) {
|
|
242
|
+
taggedEqual(js.results[i], php.results[i], `op #${i} ${OPS[i].op} ${OPS[i].key || ''}`);
|
|
243
|
+
}
|
|
244
|
+
assert.deepEqual(js.rows, php.rows, 'final cache rows');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('DatabaseCacheStore: get(key, default) returns the default on miss', async () => {
|
|
248
|
+
const sqlite = makeSqlite();
|
|
249
|
+
const store = new DatabaseCacheStore(d1(sqlite), { table: 'cache', prefix: PREFIX, clock: () => NOW });
|
|
250
|
+
assert.equal(await store.get('nope'), null);
|
|
251
|
+
assert.equal(await store.get('nope', 7), 7);
|
|
252
|
+
assert.equal(await store.get('nope', 'x'), 'x');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('DatabaseCacheStore: requires a D1 binding', () => {
|
|
256
|
+
assert.throws(() => new DatabaseCacheStore(null), /D1 binding/);
|
|
257
|
+
assert.throws(() => new DatabaseCacheStore({}), /D1 binding/);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('DatabaseCacheStore: increment clamps PHP int cast (string current)', async () => {
|
|
261
|
+
const sqlite = makeSqlite();
|
|
262
|
+
const store = new DatabaseCacheStore(d1(sqlite), { table: 'cache', prefix: PREFIX, clock: () => NOW });
|
|
263
|
+
await store.put('s', 'not numeric', 60);
|
|
264
|
+
assert.equal(await store.increment('s', 1), false);
|
|
265
|
+
assert.equal(await store.get('s'), 'not numeric');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('DatabaseCacheStore: prefix is applied inside the store', async () => {
|
|
269
|
+
const sqlite = makeSqlite();
|
|
270
|
+
const store = new DatabaseCacheStore(d1(sqlite), { table: 'cache', prefix: PREFIX, clock: () => NOW });
|
|
271
|
+
await store.put('k', 'v', 60);
|
|
272
|
+
const rows = readRows(sqlite);
|
|
273
|
+
assert.equal(rows.length, 1);
|
|
274
|
+
assert.equal(rows[0].key, PREFIX + 'k');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('a TTL of 0 forgets the key (Laravel: Repository::put)', async () => {
|
|
278
|
+
// Laravel's Repository::put() (Cache/Repository.php:367-380): a NULL ttl
|
|
279
|
+
// means "forever"; a ttl of 0 (or less) FORGETS the key
|
|
280
|
+
// (`if ($seconds <= 0) { return $this->forget($key); }`). The edge stored it
|
|
281
|
+
// for ten years (now + 315360000).
|
|
282
|
+
const sqlite = makeSqlite();
|
|
283
|
+
const store = new DatabaseCacheStore(d1(sqlite), { table: 'cache', prefix: PREFIX, clock: () => NOW });
|
|
284
|
+
await store.put('k', 'v', 60);
|
|
285
|
+
assert.notEqual(await store.get('k'), null, 'the key is stored');
|
|
286
|
+
await store.put('k', 'v', 0);
|
|
287
|
+
assert.equal(await store.get('k'), null, 'a ttl of 0 forgets the key');
|
|
288
|
+
await store.put('k2', 'v');
|
|
289
|
+
assert.notEqual(await store.get('k2'), null, 'no ttl stores the key (forever)');
|
|
290
|
+
});
|