@vibes.diy/prompts 5.5.17 → 6.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.
- package/llms/fireproof.md +15 -1
- package/llms/voxel.md +295 -17
- package/package.json +4 -4
package/llms/fireproof.md
CHANGED
|
@@ -374,9 +374,23 @@ Each capability (`read`, `write`, `delete`) is independent. Omitting one falls b
|
|
|
374
374
|
|
|
375
375
|
Other `acl` variants: `useFireproof("drafts", { acl: { read: ["editors"], write: ["editors"], delete: ["editors"] } })` for editors-only space, or omit `acl` entirely to fall back to app-level role gates (existing behavior, always safe).
|
|
376
376
|
|
|
377
|
+
## Offline writes are on by default (`offlineQueue`)
|
|
378
|
+
|
|
379
|
+
For signed-in users, writes are **local-first by default**: a `put`/`del` that fails on the network is durably queued on the device (it resolves, stays visible, and syncs to the cloud when you're back online) instead of rolling back. You don't opt in — every signed-in vibe gets this. A server rejection (access-denied, validation) still rolls back and throws; only transport failures queue.
|
|
380
|
+
|
|
381
|
+
Pass `{ offlineQueue: false }` for **server-first, fail-fast** writes: a `put` resolves only when the server accepts it, and a network failure rejects immediately with nothing queued. Choose this for **collaborative multi-writer apps** where two people may edit the same doc — sync is blind last-arrival-wins (Firefly has no `_rev`), so a stale write replayed on reconnect can silently overwrite a newer one. When a lost write is safer than a surprise overwrite, opt out.
|
|
382
|
+
|
|
383
|
+
```js
|
|
384
|
+
// Default (single-user apps): durable offline writes, no config needed.
|
|
385
|
+
const { useLiveQuery } = useFireproof("todos");
|
|
386
|
+
|
|
387
|
+
// Collaborative app: fail fast instead of queueing a stale write.
|
|
388
|
+
const { useLiveQuery } = useFireproof("shared-board", { offlineQueue: false });
|
|
389
|
+
```
|
|
390
|
+
|
|
377
391
|
## Anonymous local writes (`anonymousLocal`)
|
|
378
392
|
|
|
379
|
-
For "let a logged-out visitor try it and save a little state before signing in," pass `{ anonymousLocal: true }`. While logged out, `put`/`del`/`useLiveQuery`/`useDocument` run against a local (localStorage) store with the identical API — no auth branching in your code. On first sign-in the local docs migrate into the cloud database, then local storage clears.
|
|
393
|
+
For "let a logged-out visitor try it and save a little state before signing in," pass `{ anonymousLocal: true }`. While logged out, `put`/`del`/`useLiveQuery`/`useDocument` run against a local (localStorage) store with the identical API — no auth branching in your code. On first sign-in the local docs migrate into the cloud database, then local storage clears. (This is the signed-**out** read-path opt-in; the offline write queue above is separate and already on for signed-in users.)
|
|
380
394
|
|
|
381
395
|
```js
|
|
382
396
|
const { useLiveQuery, database } = useFireproof("favorites", {
|
package/llms/voxel.md
CHANGED
|
@@ -311,29 +311,296 @@ when a block breaks — cheap, and it makes mining feel real.
|
|
|
311
311
|
|
|
312
312
|
## Multiplayer: live block edits over Fireproof
|
|
313
313
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
**
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
314
|
+
**Know exactly what the substrate gives you before you design for it.** A live
|
|
315
|
+
query returns the **full, latest-per-document result set**, deliveries are
|
|
316
|
+
**unordered** (notifications can be delayed; overlapping re-queries can resolve
|
|
317
|
+
out of order), and when two writes target the **same `_id`** the durable head is
|
|
318
|
+
whichever the **server wrote last by arrival order — not the higher client
|
|
319
|
+
timestamp.** That last fact is the one that bites: a client-side fold can order
|
|
320
|
+
what a session *observes*, but it cannot change which revision the server keeps,
|
|
321
|
+
so **there is no free lunch here.** Pick a model with eyes open:
|
|
322
|
+
|
|
323
|
+
- **Model A — one doc per coordinate (bounded, eventually-consistent-per-session).**
|
|
324
|
+
Deterministic `_id: block:<key>`, so each edit *overwrites* the same doc and the
|
|
325
|
+
database stays one-doc-per-cell. A client fold makes every session that
|
|
326
|
+
*observes* both of two concurrent writes converge on the same winner. **Its
|
|
327
|
+
honest limit:** the durable head at a coordinate is whatever persisted last by
|
|
328
|
+
server order, so a **late joiner** — who only ever receives that one head —
|
|
329
|
+
can see a *lower*-timestamp write win. And because the fold orders by
|
|
330
|
+
client-supplied `(at, wid)`, even among *accepted* writes a skewed writer's
|
|
331
|
+
clock can transiently order a lower-real-time edit ahead until the skew
|
|
332
|
+
window passes — the stamps order by *observed* time, not truth. Fine for a
|
|
333
|
+
casual shared toy; not actually strong consistency, and only server-assigned
|
|
334
|
+
ordering ([#3777](https://github.com/VibesDIY/vibes.diy/issues/3777)) removes
|
|
335
|
+
the client-clock dependence entirely. This is the model shown below and used
|
|
336
|
+
by the example app.
|
|
337
|
+
- **Model B — append-only op log (unbounded, strongly convergent).** Every edit
|
|
338
|
+
is its **own immutable doc** (unique `_id`, never overwritten), so the full set
|
|
339
|
+
of ops is durably present and *every* client — late joiners included — folds
|
|
340
|
+
the identical total order to the identical world. This is the model to reach
|
|
341
|
+
for when correctness across joiners matters. **Its cost:** the doc set grows
|
|
342
|
+
with every edit; you must compact (delete ops below the reset watermark, or
|
|
343
|
+
snapshot). The delta from Model A is small and called out after the code.
|
|
344
|
+
- **The real fix for bounded *and* strong is server-assigned ordering** (a
|
|
345
|
+
compare-and-set on `at`, or a server write-sequence exposed to the fold). The
|
|
346
|
+
platform does not expose that yet — [#3777](https://github.com/VibesDIY/vibes.diy/issues/3777).
|
|
347
|
+
Until it does, neither model is both bounded and joiner-correct, and this
|
|
348
|
+
section says so rather than pretending a client fold closes the gap.
|
|
349
|
+
|
|
350
|
+
### Model A: one doc per coordinate
|
|
351
|
+
|
|
352
|
+
Give the doc a **deterministic `_id` of `block:<key>`** so every edit at a
|
|
353
|
+
coordinate updates the same doc — the database stays one-doc-per-cell instead of
|
|
354
|
+
replaying every historical value. A break writes a `removed: true` tombstone at
|
|
321
355
|
the same id (keep it so the removal survives streaming, rather than deleting it):
|
|
322
356
|
|
|
323
357
|
```js
|
|
324
|
-
//
|
|
358
|
+
// A per-WRITE unique id. It is the tiebreaker AND the identity: authorHandle is
|
|
359
|
+
// neither (two tabs on one handle, or two anonymous players, collide), and `by`
|
|
360
|
+
// defaulting to "" would let distinct writes tie and fall back to arrival order.
|
|
361
|
+
const wid = crypto.randomUUID();
|
|
362
|
+
|
|
363
|
+
// on place/break — apply locally for instant feedback, fold the intent into
|
|
364
|
+
// the world map NOW, then upsert the ONE doc for this cell.
|
|
365
|
+
// Stamp with a STRICTLY monotonic local time: never <= our previous write
|
|
366
|
+
// (Date.now() can tie or step backwards), and always above the reset watermark.
|
|
367
|
+
// We clamp OUR OWN clock here on write, so an honest client never emits a
|
|
368
|
+
// future-dated stamp; readers drop the rest (see fold).
|
|
325
369
|
eng.applyBlock(key, type); // type=null for a break
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
370
|
+
const at = (lastAtRef.current = Math.max(Date.now(), lastAtRef.current + 1, resetAtRef.current + 1));
|
|
371
|
+
const doc = brk
|
|
372
|
+
? { _id: `block:${key}`, type: "block", key, removed: true, authorHandle: me?.userHandle, wid, at }
|
|
373
|
+
: { _id: `block:${key}`, type: "block", key, blockType: type, authorHandle: me?.userHandle, wid, at };
|
|
374
|
+
if (wins(at, wid, blockStateRef.current.get(key)))
|
|
375
|
+
blockStateRef.current.set(key, { at, wid, doc }); // local write folds in at put time
|
|
376
|
+
await database.put(doc);
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
### Consistency: fold snapshots FORWARD — never rebuild the world from a snapshot
|
|
380
|
+
|
|
381
|
+
Because deliveries are unordered, a snapshot missing your newest write can land
|
|
382
|
+
*after* one that had it. If each delivery rebuilds the world from scratch, that
|
|
383
|
+
stale delivery silently resurrects old state: **mined blocks visibly fill back
|
|
384
|
+
in behind the player, then "stabilize" back to correct when a fresh delivery
|
|
385
|
+
lands** — the single most disorienting multiplayer bug a voxel game can have.
|
|
386
|
+
The rule that kills it: **a voxel only ever moves forward in time.** Keep a
|
|
387
|
+
monotonic accumulator keyed by coordinate holding `{ at, wid, doc }` and fold
|
|
388
|
+
every snapshot into it — an older write can never overwrite a newer one. Local
|
|
389
|
+
writes fold in at put time (above), so the player's own intent is the newest
|
|
390
|
+
state by definition and no delivery ordering can outrace it:
|
|
391
|
+
|
|
392
|
+
```js
|
|
393
|
+
const MAX_SKEW = 30_000; // reject another client's clock more than ~30s ahead
|
|
394
|
+
const blockStateRef = React.useRef(new Map()); // key -> { at, wid, doc }
|
|
395
|
+
const lastAtRef = React.useRef(0); // makes our own stamps strictly increasing
|
|
396
|
+
// One deterministic TOTAL order, used for local folds and snapshot folds alike:
|
|
397
|
+
// higher at wins; equal at breaks the tie by the per-write uuid, so every client
|
|
398
|
+
// converges to the same value with no collisions and no arrival-order dependence.
|
|
399
|
+
const wins = (at, wid, cur) =>
|
|
400
|
+
!cur || at > cur.at || (at === cur.at && wid > cur.wid);
|
|
401
|
+
|
|
402
|
+
// The reset watermark must be monotonic ACROSS deliveries — a stale, out-of-
|
|
403
|
+
// order snapshot of the worldReset singleton must never lower it. Fold it into
|
|
404
|
+
// a ref with max(previous, incoming); mirror to state so the block fold re-runs.
|
|
405
|
+
// CRITICAL: apply the SAME future-stamp filter as blocks. The watermark only
|
|
406
|
+
// ever rises AND is destructive (it clears the world and forces local writes
|
|
407
|
+
// above it), so a single future-dated reset is far more damaging than a future
|
|
408
|
+
// block — it would poison the watermark permanently. Drop resets beyond the
|
|
409
|
+
// horizon here, and reject them server-side in access.js (below).
|
|
410
|
+
const resetAtRef = React.useRef(0);
|
|
411
|
+
const [resetAt, setResetAt] = React.useState(0);
|
|
412
|
+
const { docs: resetDocs } = useLiveQuery("type", { key: "worldReset" });
|
|
413
|
+
React.useEffect(() => {
|
|
414
|
+
const horizon = Date.now() + MAX_SKEW;
|
|
415
|
+
const hi = resetDocs.reduce((mx, d) => (d.at && d.at <= horizon ? Math.max(mx, d.at) : mx), resetAtRef.current);
|
|
416
|
+
if (hi > resetAtRef.current) { resetAtRef.current = hi; setResetAt(hi); }
|
|
417
|
+
}, [resetDocs]);
|
|
418
|
+
|
|
419
|
+
// Time-driven re-fold: a near-future write dropped by the skew filter becomes
|
|
420
|
+
// eligible once wall time passes its stamp. Without this tick the fold only
|
|
421
|
+
// re-runs on a new delivery, so in a quiet world a legit slightly-ahead write
|
|
422
|
+
// would stay dropped. `tick` is a fold dependency below.
|
|
423
|
+
const [tick, setTick] = React.useState(0);
|
|
424
|
+
React.useEffect(() => {
|
|
425
|
+
const id = setInterval(() => setTick((t) => t + 1), MAX_SKEW);
|
|
426
|
+
return () => clearInterval(id);
|
|
427
|
+
}, []);
|
|
329
428
|
|
|
330
|
-
// stream everyone's edits back into the engine — one current doc per coordinate
|
|
331
429
|
const { docs: blockDocs } = useLiveQuery("type", { key: "block" });
|
|
430
|
+
// Fold in an EFFECT, not during render: the accumulator is a mutable store,
|
|
431
|
+
// and mutating it in a useMemo is a render-phase side effect (unsafe under
|
|
432
|
+
// concurrent/strict rendering even when the fold is idempotent).
|
|
433
|
+
const [blocks, setBlocks] = React.useState(() => new Map());
|
|
434
|
+
React.useEffect(() => {
|
|
435
|
+
const st = blockStateRef.current;
|
|
436
|
+
const horizon = Date.now() + MAX_SKEW;
|
|
437
|
+
for (const d of blockDocs) {
|
|
438
|
+
const at = d.at || 0, wid = d.wid || "";
|
|
439
|
+
if (at <= resetAt) continue; // watermark filters EVERY fold — deliveries re-ship old docs forever
|
|
440
|
+
if (at > horizon) continue; // Defense-in-depth: skip a stamp beyond the horizon THIS fold. The
|
|
441
|
+
// authoritative bound is server-side (access.js rejects at > now+skew
|
|
442
|
+
// at WRITE time), so a far-future doc should never persist; this local
|
|
443
|
+
// skip just absorbs a small clock lead until the tick re-admits it.
|
|
444
|
+
// NOTE: with the tick, this drop is NOT a durable filter — a fold that
|
|
445
|
+
// re-runs after wall time passes `at` will admit it. Keeping hostile
|
|
446
|
+
// far-future state out is the SERVER's job, not this line's.
|
|
447
|
+
if (wins(at, wid, st.get(d.key))) st.set(d.key, { at, wid, doc: d });
|
|
448
|
+
}
|
|
449
|
+
if (resetAt) for (const [k, v] of st) { if (v.at <= resetAt) st.delete(k); } // drop held entries when the watermark advances
|
|
450
|
+
const m = new Map();
|
|
451
|
+
for (const [k, v] of st) m.set(k, v.doc.removed ? null : v.doc.blockType);
|
|
452
|
+
setBlocks(m);
|
|
453
|
+
}, [blockDocs, resetAt, tick]);
|
|
454
|
+
// Apply to the engine — including keys that DISAPPEARED (a reset purged them):
|
|
455
|
+
// only pushing present keys would leave pre-reset blocks rendered forever.
|
|
456
|
+
// applyBlock(k, null) means "revert to generated terrain" in the engine model.
|
|
457
|
+
const prevKeysRef = React.useRef(new Set());
|
|
332
458
|
React.useEffect(() => {
|
|
333
|
-
|
|
334
|
-
|
|
459
|
+
const seen = new Set();
|
|
460
|
+
for (const [k, b] of blocks) { seen.add(k); eng.applyBlock(k, b); }
|
|
461
|
+
for (const k of prevKeysRef.current) if (!seen.has(k)) eng.applyBlock(k, null);
|
|
462
|
+
prevKeysRef.current = seen;
|
|
463
|
+
}, [blocks]);
|
|
335
464
|
```
|
|
336
465
|
|
|
466
|
+
### Model B: append-only op log (the delta) — design sketch, not drop-in code
|
|
467
|
+
|
|
468
|
+
The runnable example above is Model A. Model B is described as a **delta** — the
|
|
469
|
+
invariants and access branches are exact, but the fold/compaction below is a
|
|
470
|
+
**design sketch, not copy-paste code**: you must wire the snapshot query,
|
|
471
|
+
validation, and seeding into your own fold (spelled out under Compaction), and
|
|
472
|
+
for a production world you likely want server ordering ([#3777](https://github.com/VibesDIY/vibes.diy/issues/3777))
|
|
473
|
+
rather than hand-rolling compaction. Treat it as "here is the shape and the
|
|
474
|
+
traps," not a finished implementation.
|
|
475
|
+
|
|
476
|
+
To make **late joiners** correct too, stop overwriting: give every edit its own
|
|
477
|
+
immutable doc and never `put` the same `_id` twice.
|
|
478
|
+
|
|
479
|
+
- **Write:** `_id: op:${wid}` instead of `block:<key>` (each op is a distinct,
|
|
480
|
+
never-updated doc); keep `key`, `at`, `wid`, and `blockType`/`removed` as-is.
|
|
481
|
+
- **Fold:** unchanged — the same `wins(at, wid, …)` reduction over the op set
|
|
482
|
+
produces one winner per `key`. Because *no doc is ever overwritten*, the full
|
|
483
|
+
history is durably present, so any client that has received the same set of ops
|
|
484
|
+
folds to the same world regardless of join time — that is what makes it
|
|
485
|
+
convergent where Model A is only per-session. **The one caveat is the shared
|
|
486
|
+
future-stamp filter:** two clients whose clocks straddle a near-future op's
|
|
487
|
+
stamp momentarily include different op sets, so identical convergence is
|
|
488
|
+
*eventual* (it holds once every client's horizon passes the op, or absent
|
|
489
|
+
adversarial future stamps) — the same client-clock residual as Model A, closed
|
|
490
|
+
only by server ordering ([#3777](https://github.com/VibesDIY/vibes.diy/issues/3777)).
|
|
491
|
+
- **Compaction (required, but it needs a completeness boundary):** the op set
|
|
492
|
+
grows with every edit, and a live query that re-ships an ever-growing set
|
|
493
|
+
eventually stalls. Two things make a *delete* safe, and both matter:
|
|
494
|
+
1. **The fold must read the snapshot it produces.** Fold the world into one
|
|
495
|
+
`snapshot` doc `{ at: <cut>, blocks: {key → blockType} }`; the fold then
|
|
496
|
+
**seeds the accumulator from the latest snapshot** (prime `blockStateRef`
|
|
497
|
+
from `snapshot.blocks` at `snapshot.at`) and applies only ops with
|
|
498
|
+
`at > snapshot.at`. Without this a late joiner reconstructs from partial
|
|
499
|
+
history.
|
|
500
|
+
2. **You may only delete ops the snapshot provably covers.** The compactor's
|
|
501
|
+
own query is eventually-consistent, so "delete every op with `at <= cut`"
|
|
502
|
+
is **unsafe** — an op at `at <= cut` that hadn't yet reached the compactor
|
|
503
|
+
is deleted forever, and every late joiner then reconstructs the wrong
|
|
504
|
+
world. The only boundary a *client* can trust is the owner's `worldReset`
|
|
505
|
+
watermark: ops below it are already semantically void (the reset voided
|
|
506
|
+
them), so deleting `at <= resetAt` is safe and is the degenerate snapshot
|
|
507
|
+
(an empty world at `resetAt`). Reclaiming space in a **long-lived, never-
|
|
508
|
+
reset** world needs a completeness guarantee no client query provides —
|
|
509
|
+
that is server-coordinated compaction (the server vouches the snapshot saw
|
|
510
|
+
every op `<= cut`), i.e. the same server-ordering gap as
|
|
511
|
+
[#3777](https://github.com/VibesDIY/vibes.diy/issues/3777). Until then,
|
|
512
|
+
compact only at resets, or accept unbounded growth.
|
|
513
|
+
- **Access — Model B needs its own branches** (the shown `access.js` is Model A
|
|
514
|
+
and would *reject* every Model B write). Two things make the "append-only"
|
|
515
|
+
claim real, and both are easy to miss: **op docs must be create-only** (an open
|
|
516
|
+
branch that ignores `oldDoc` lets anyone who sees an `op:<wid>` rewrite that
|
|
517
|
+
supposedly-immutable op), and **deletes must be owner-only** (a delete is a
|
|
518
|
+
put of a tombstone under delete-as-put, so `allowAnonymous` would otherwise let
|
|
519
|
+
any client erase history and blow past the compaction boundary). Gate deletes
|
|
520
|
+
once at the top, keep the op branch create-only, and add the owner `snapshot`
|
|
521
|
+
singleton:
|
|
522
|
+
```js
|
|
523
|
+
// Deletes exist ONLY for owner op-compaction, and ONLY for op docs. Route by
|
|
524
|
+
// oldDoc (the doc being removed), never by the tombstone's own fields. A blanket
|
|
525
|
+
// owner-delete would let db.del drop world:reset (lowering the watermark, so
|
|
526
|
+
// pre-reset ops flood back to a late joiner) or world:snapshot (stranding late
|
|
527
|
+
// joiners) — so reject those explicitly. NOTE: the documented `at <= resetAt`
|
|
528
|
+
// compaction boundary itself CANNOT be enforced here — the access fn can't read
|
|
529
|
+
// the current watermark (no doc reads in ctx) — so a client is trusted to only
|
|
530
|
+
// delete voided ops; a server revision/CAS would make it enforceable (#3777).
|
|
531
|
+
if (doc._deleted) {
|
|
532
|
+
if (!user) throw { forbidden: "sign in" };
|
|
533
|
+
ctx.requireRole("owner");
|
|
534
|
+
if (!oldDoc) throw { forbidden: "nothing to delete" };
|
|
535
|
+
if (oldDoc._id === "world:reset" || oldDoc._id === "world:snapshot") throw { forbidden: "reset/snapshot are not deletable" };
|
|
536
|
+
if (oldDoc.type !== "block") throw { forbidden: "only op docs are compactable" };
|
|
537
|
+
return { channels: [WORLD], grant: { public: [WORLD] } };
|
|
538
|
+
}
|
|
539
|
+
if (doc.type === "block") {
|
|
540
|
+
// op-log id, unique per write — NOT the block:<key> pin
|
|
541
|
+
if (doc._id !== `op:${doc.wid}`) throw { forbidden: "op docs live at op:<wid>" };
|
|
542
|
+
// CREATE-ONLY: a live op is immutable. Any update (oldDoc present) is a
|
|
543
|
+
// rewrite of history — reject it, or "append-only" is a lie on an open db.
|
|
544
|
+
if (oldDoc) throw { forbidden: "op docs are append-only" };
|
|
545
|
+
if (user && doc.authorHandle && doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
546
|
+
if (typeof doc.at !== "number" || doc.at > Date.now() + MAX_SKEW) throw { forbidden: "stamp too far in the future" };
|
|
547
|
+
return { channels: [WORLD], grant: { public: [WORLD] }, allowAnonymous: true };
|
|
548
|
+
}
|
|
549
|
+
if (doc.type === "snapshot") {
|
|
550
|
+
// Group-destructive (it authorizes op deletion) — owner only, fixed _id, and
|
|
551
|
+
// MONOTONIC. It is one durable head selected by server arrival order, so
|
|
552
|
+
// without the monotonic guard an older snapshot arriving late overwrites a
|
|
553
|
+
// newer one — and once ops below the newer cut are deleted a late joiner
|
|
554
|
+
// seeding the regressed snapshot can't rebuild. Same guard as worldReset;
|
|
555
|
+
// the fold must seed only from a snapshot whose `at >= resetAt`.
|
|
556
|
+
if (!user) throw { forbidden: "sign in" };
|
|
557
|
+
ctx.requireRole("owner");
|
|
558
|
+
if (doc._id !== "world:snapshot") throw { forbidden: "singleton lives at world:snapshot" };
|
|
559
|
+
if (typeof doc.at !== "number" || (oldDoc && !(doc.at > oldDoc.at))) throw { forbidden: "snapshot cut must move forward" };
|
|
560
|
+
return { channels: [WORLD], grant: { public: [WORLD] } };
|
|
561
|
+
}
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
Ordering rules, each load-bearing (both models):
|
|
565
|
+
|
|
566
|
+
- **Bound future stamps SERVER-SIDE; the client drop is only defense-in-depth.**
|
|
567
|
+
An honest client stamps `max(now, last+1, resetAt+1)`, so it never emits a
|
|
568
|
+
future `at`. The authoritative guard against a hostile future stamp is in
|
|
569
|
+
`access.js`: reject any `block` or `worldReset` write with `at > now + MAX_SKEW`
|
|
570
|
+
so it never persists. The client fold *also* skips docs beyond its horizon, but
|
|
571
|
+
do **not** rely on that to keep hostile state out — because the fold re-runs on
|
|
572
|
+
the tick, a client-only skip is eventually undone once wall time passes the
|
|
573
|
+
stamp (a `now + 1h` block would surface ~an hour later). The client skip's real
|
|
574
|
+
job is smaller: absorb a legit small clock lead until the tick re-admits it.
|
|
575
|
+
The reset needs the same server bound *and* the client horizon filter, because
|
|
576
|
+
its watermark is monotonic and destructive — a single future reset that slips
|
|
577
|
+
through is the worst case. And **do not** clamp-and-remember on read — a
|
|
578
|
+
per-client clamp diverges between clients and can't be made Byzantine-safe.
|
|
579
|
+
Only server-assigned ordering
|
|
580
|
+
([#3777](https://github.com/VibesDIY/vibes.diy/issues/3777)) removes the
|
|
581
|
+
client-clock dependence entirely.
|
|
582
|
+
- **The order must be total, identical everywhere, and collision-free.** Break
|
|
583
|
+
equal-`at` ties by a **per-write uuid** (`wid`) in every fold. `authorHandle`
|
|
584
|
+
is not an identity (same-handle tabs, anonymous players tie) and a
|
|
585
|
+
session-scoped id still collides across a session's own writes; only a
|
|
586
|
+
per-write uuid is total. A server-assigned revision, when available, replaces
|
|
587
|
+
the whole `(at, wid)` scheme.
|
|
588
|
+
- **The reset watermark must gate every fold, and be monotonic across
|
|
589
|
+
deliveries.** Deliveries re-ship the full doc set forever, so a purged
|
|
590
|
+
pre-reset doc comes straight back unless the fold rejects `at <= resetAt`
|
|
591
|
+
every time. The watermark itself must only ever rise: fold the `worldReset`
|
|
592
|
+
singleton into a ref with `max(previous, incoming)`, or an out-of-order stale
|
|
593
|
+
snapshot lowers it and readmits pre-reset blocks. Local writes stamp above it
|
|
594
|
+
by construction (the `resetAtRef.current + 1` term).
|
|
595
|
+
- **Propagate disappearances to the engine.** A reset removes keys from the
|
|
596
|
+
derived map; if you only apply the keys that are present, everything mined
|
|
597
|
+
before the reset stays rendered. Diff against the previous key set and
|
|
598
|
+
apply `null` (revert-to-terrain) for keys that vanished.
|
|
599
|
+
- **Clear an optimistic overlay only when the synced value AGREES with it**,
|
|
600
|
+
never when the key merely exists. A re-edited voxel always has an older doc,
|
|
601
|
+
so key-presence clears the overlay against stale data — the same refill bug
|
|
602
|
+
through a different door. `if (blocks.get(k) === pending.get(k)) pending.delete(k)`.
|
|
603
|
+
|
|
337
604
|
Apply the local edit **before** the `put` so the world never waits on the
|
|
338
605
|
network, and read live handles/permissions through refs (a value captured in the
|
|
339
606
|
engine closure at load time goes stale — bind live state via `state.on*`
|
|
@@ -347,6 +614,7 @@ with their natural owner. Every type the app writes needs its own branch
|
|
|
347
614
|
```js
|
|
348
615
|
export default function (doc, oldDoc, user, ctx) {
|
|
349
616
|
const WORLD = "world:overworld";
|
|
617
|
+
const MAX_SKEW = 30_000;
|
|
350
618
|
// No branch may change a doc's type: without this, the open block branch
|
|
351
619
|
// could squat a reserved _id (inv:<handle>, world:reset) and lock out the
|
|
352
620
|
// legitimate writer.
|
|
@@ -356,6 +624,11 @@ export default function (doc, oldDoc, user, ctx) {
|
|
|
356
624
|
// accepted arbitrary _ids could occupy other types' reserved keys.
|
|
357
625
|
if (doc._id !== `block:${doc.key}`) throw { forbidden: "block docs live at block:<key>" };
|
|
358
626
|
if (user && doc.authorHandle && doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
627
|
+
// Bound the stamp SERVER-SIDE — this, not the client fold, is what keeps a
|
|
628
|
+
// hostile far-future write out. The client skip is undone once the tick's
|
|
629
|
+
// wall clock passes `at`; only a write-time rejection prevents a `now + 1h`
|
|
630
|
+
// block from persisting and surfacing an hour later.
|
|
631
|
+
if (typeof doc.at !== "number" || doc.at > Date.now() + MAX_SKEW) throw { forbidden: "block stamp too far in the future" };
|
|
359
632
|
return { channels: [WORLD], grant: { public: [WORLD] }, allowAnonymous: true };
|
|
360
633
|
}
|
|
361
634
|
if (doc.type === "inventory") {
|
|
@@ -384,6 +657,10 @@ export default function (doc, oldDoc, user, ctx) {
|
|
|
384
657
|
ctx.requireRole("owner");
|
|
385
658
|
if (doc._id !== "world:reset") throw { forbidden: "singleton lives at world:reset" };
|
|
386
659
|
if (typeof doc.at !== "number" || (oldDoc && !(doc.at > oldDoc.at))) throw { forbidden: "watermark must move forward" };
|
|
660
|
+
// Reject a far-future stamp SERVER-SIDE: the watermark is monotonic and
|
|
661
|
+
// destructive, so a future-dated reset would poison it for everyone and
|
|
662
|
+
// survive the client-side skew drop on the writer's own machine.
|
|
663
|
+
if (doc.at > Date.now() + MAX_SKEW) throw { forbidden: "reset stamp too far in the future" };
|
|
387
664
|
return { channels: [WORLD], grant: { public: [WORLD] } };
|
|
388
665
|
}
|
|
389
666
|
throw { forbidden: "unknown document type" };
|
|
@@ -472,6 +749,7 @@ None of this needs persistence — keep it purely visual and client-side:
|
|
|
472
749
|
draw distance.
|
|
473
750
|
|
|
474
751
|
That set — exposed-face meshing, per-axis collision with step-up and un-stick,
|
|
475
|
-
a click-to-play pointer-lock overlay, a DDA raycast, per-voxel Fireproof docs
|
|
476
|
-
table-driven survival systems, and a flat
|
|
477
|
-
what turns a voxel tech demo into a game
|
|
752
|
+
a click-to-play pointer-lock overlay, a DDA raycast, per-voxel Fireproof docs
|
|
753
|
+
folded forward monotonically, table-driven survival systems, and a flat
|
|
754
|
+
multi-file split once it grows — is what turns a voxel tech demo into a game
|
|
755
|
+
people actually play.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibes.diy/prompts",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "6.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./index.js",
|
|
6
6
|
"description": "",
|
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
"license": "Apache-2.0",
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@adviser/cement": "~0.5.34",
|
|
27
|
-
"@vibes.diy/call-ai-v2": "^
|
|
28
|
-
"@vibes.diy/identity": "^
|
|
29
|
-
"@vibes.diy/use-vibes-types": "^
|
|
27
|
+
"@vibes.diy/call-ai-v2": "^6.0.0",
|
|
28
|
+
"@vibes.diy/identity": "^6.0.0",
|
|
29
|
+
"@vibes.diy/use-vibes-types": "^6.0.0",
|
|
30
30
|
"arktype": "~2.2.3",
|
|
31
31
|
"json-schema-faker": "~0.6.2"
|
|
32
32
|
},
|