nervur 0.22.2-3 → 0.22.2-5

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/AUTHORING.md CHANGED
@@ -144,6 +144,12 @@ and TypeScript reads from it the types of her cells and her needs.
144
144
  | `roles` | named tests over the asker and her cells |
145
145
  | `state` | a function of the being that names her current state |
146
146
  | `asks` | one entry per method she answers |
147
+ | `view` | markup as text over her asks, which a screen renders |
148
+
149
+ A view is data, at most 64 KiB, carried in her describe to every asker.
150
+ It holds no script, and a renderer loads nothing it names, so a view
151
+ neither acts nor tracks. The library defines no markup: a renderer
152
+ speaks its own.
147
153
 
148
154
  Every field is optional but `kind` and `asks`. A class with no `state`
149
155
  has one state, named `ready`. The kind is how the house finds her code
@@ -195,7 +201,7 @@ nothing lands.
195
201
  | `this.house.alarm({ at, ask, args, key })` | one of her asks, at that time |
196
202
  | `this.house.cancelAlarm({ key })` | an alarm removed |
197
203
  | `this.<need>.<method>({…}, { reply? })` | an awaited call, or an effect |
198
- | `this.held(id, Need).<ask>({…}, { reply? })` | the same, on a standing |
204
+ | `this.held(id, Need).<ask>({…}, { reply?, after? })` | the same, on a standing, and a watch where `after` is given |
199
205
  | `this.standings` | `list()`, `note(id, notes)` and `drop(id)` |
200
206
  | `this.handle(ask, { bind?, notes?, once?, expires? })` | a handle to one of her asks |
201
207
  | `this.invite(id, { notes?, expires? })` | a new occupant, as a handle |
@@ -239,6 +245,29 @@ one receiver leave one at a time, in the order she called them.
239
245
  Asks to one being run one at a time, in the order they arrive. A
240
246
  `readOnly` ask runs beside that queue, on the cells last landed.
241
247
 
248
+ ### Watching an answer
249
+
250
+ A watch is a `readOnly` ask asked with `after`, the answer you already
251
+ hold. The house answers at once where the answer differs. Where it is
252
+ the same, the house holds the ask and runs it again each time an ask of
253
+ that being lands. It answers the first answer that differs, or the same
254
+ one when the wait runs out. So a chat, an order's status or a dashboard
255
+ is a loop of watches, and nothing polls.
256
+
257
+ Through the hand, pass `after` beside the method, as the answer you
258
+ hold: `{ result: [...] }`. From a being, pass the result she holds:
259
+ `this.held(room, Messages).messages({}, { after: seen })`, inside a
260
+ `readOnly` ask of her own, so she stays free while she waits. Only a
261
+ `readOnly` ask is watched, and a watch on anything else is refused where
262
+ she makes it.
263
+
264
+ A watch moves only on what its asker could read, since it runs as that
265
+ asker. One asker holds one watch on one ask with the same args, and a
266
+ second answers the first at once. A watch across a door holds its
267
+ relation until it answers, since Quo moves a relation one ask at a time.
268
+ So watch a far being through a handle to the watched ask alone, a
269
+ relation of its own, and ask everything else on your other standing.
270
+
242
271
  ### Relations
243
272
 
244
273
  An **occupant** is someone who may ask her. She mints one with
@@ -719,6 +748,50 @@ A user unit stops when its user logs out, unless lingering is enabled
719
748
  with `loginctl enable-linger`. On macOS, the agent goes to
720
749
  `~/Library/LaunchAgents/` and is started with `launchctl bootstrap`.
721
750
 
751
+ ### In a page
752
+
753
+ `BrowserGround.open({ recipe })` from `nervur/browser` runs the same
754
+ houses in a page. Every tab and the service worker of one origin share
755
+ one ground: the one holding the Web Lock runs it, and the others reach
756
+ its hand over a `BroadcastChannel`. When it closes, the next opens the
757
+ ground from the same storage. `hand` answers the same three requests
758
+ the command sends: `describe`, a faculty's method, and an ask of a being
759
+ in a named house.
760
+
761
+ Its bodies are the browser's. Seeds are sealed under an AES key that
762
+ IndexedDB holds unextractable, and memory is IndexedDB, named
763
+ `indexeddb` in an entry. Classes load from the page's own origin through
764
+ the `origin` body, `{ body: 'origin', at: '/house/index.js' }`, and a
765
+ path off the origin is refused. The recipe is the object you pass: its
766
+ faculties, made by a function, and any custom body.
767
+
768
+ Any script on the origin can use the ground's keys, so the ground is
769
+ whoever serves the origin's script. Give it an origin of its own, serve
770
+ nothing a stranger wrote there, and set a strict content security
771
+ policy. The page and the house's module must share one copy of
772
+ `nervur/being`, since a house knows a class by a mark that module gives.
773
+ A bundler's shared chunk or an import map gives the one copy.
774
+
775
+ A service worker opens the ground the same way, on a push, where no tab
776
+ runs it. It may not `import()`, so hand it the modules it imported
777
+ itself: `platform: { load: (href) => modules[new URL(href).pathname] }`.
778
+
779
+ ### In an app
780
+
781
+ `AppGround.open({ shell })` from `nervur/app` is a BrowserGround in the
782
+ app's web view, on two interfaces the shell fills in native code:
783
+
784
+ - `NativeSecrets`: `get(name)` and `set(name, value)`, text kept by the
785
+ iOS Keychain or the Android Keystore on this device alone.
786
+ - `NativeStore`: `get(key)`, `keys(prefix)`, and `swap(writes, expect)`,
787
+ which lands every write only where each key in `expect` still holds
788
+ what it names, and answers whether it landed.
789
+
790
+ The library holds the rest. Each house's seed goes into the secret
791
+ store, and each memory into the native store, which the system never
792
+ evicts as it may a web view's storage. A push token reaches the ground
793
+ through a faculty your recipe makes.
794
+
722
795
  ## What the house guarantees
723
796
 
724
797
  1. **Every need is covered, or she is absent.** An absent being answers
package/README.md CHANGED
@@ -107,6 +107,39 @@ the house's seed, its ledger and its record in `state/`, readable by
107
107
  you alone. Keep that folder secret: a house opens on no other seed.
108
108
  `npx nervur help` lists everything the ground offers.
109
109
 
110
+ ## A ground in a page
111
+
112
+ The same house runs in a browser. Bundle the house's module and serve it
113
+ on your origin, beside a page that opens the ground.
114
+
115
+ ```ts
116
+ // page.ts
117
+ import { BrowserGround } from 'nervur/browser';
118
+
119
+ // Every tab of this origin joins one ground, and the tab holding its lock runs it.
120
+ const ground = await BrowserGround.open();
121
+
122
+ // The house's code is a module on this origin, and its rows live in IndexedDB.
123
+ await ground.hand({
124
+ faculty: 'houses',
125
+ method: 'add',
126
+ args: { name: 'main', memory: { body: 'indexeddb' }, classes: { body: 'origin', at: '/house/index.js' } },
127
+ });
128
+
129
+ console.log(await ground.hand({ house: 'main', method: 'hello', args: { name: 'Ada' } }));
130
+ ```
131
+
132
+ Every tab of the origin reaches the one ground, and when the tab running
133
+ it closes, the next opens it from the same storage. Its seeds are sealed
134
+ under a key the browser never hands out. Any script on the origin can
135
+ use them, so give the ground an origin of its own, with a strict content
136
+ security policy. The page and the house's module must share one copy of
137
+ `nervur/being`, as a bundler's shared chunk gives.
138
+
139
+ An app on a phone opens the same ground with `AppGround` from
140
+ `nervur/app`, on the Keychain or the Keystore and a store the system
141
+ keeps. `AUTHORING.md` shows both.
142
+
110
143
  ## Entries
111
144
 
112
145
  | Entry | For |
@@ -114,6 +147,9 @@ you alone. Keep that folder secret: a house opens on no other seed.
114
147
  | `nervur/being` | writing a being: `Being`, `s`, `need`, `Args`, `Result` |
115
148
  | `nervur` | any engine: `Ground`, `House` and the bodies they take |
116
149
  | `nervur/node` | a ground on Node: `NodeGround`, the `nervur` command, and its bodies |
150
+ | `nervur/browser` | a ground in a page or its service worker: `BrowserGround` and its bodies |
151
+ | `nervur/app` | a ground in a phone's app: `AppGround`, and the two interfaces its shell fills |
152
+ | `nervur/serve` | a faculty's program in JavaScript: `serve` |
117
153
  | `nervur/bench` | tests: `Bench`, `BenchGround`, `FakeNetwork` and the fakes of every body |
118
154
 
119
155
  ## License
@@ -40,6 +40,8 @@ export class BrowserGround {
40
40
  // Asks sent to another page's ground, by id, with the page they went to.
41
41
  #pending = new Map();
42
42
  #ground;
43
+ // The custody and the memories its boot opened, each closed when it closes.
44
+ #opened = [];
43
45
  #persisted = false;
44
46
  #leader;
45
47
  #waiting = [];
@@ -178,8 +180,13 @@ export class BrowserGround {
178
180
  const name = this.#name;
179
181
  const platform = this.#platform;
180
182
  const recipe = this.#options.recipe ?? {};
181
- const custody = await platform.custody(`${name}-custody`);
182
- const memory = await platform.memory(`${name}-ground`);
183
+ // Every store it opens is kept, so its close lets each go before the lock passes.
184
+ const kept = (opened) => {
185
+ this.#opened.push(opened);
186
+ return opened;
187
+ };
188
+ const custody = kept(await platform.custody(`${name}-custody`));
189
+ const memory = kept(await platform.memory(`${name}-ground`));
183
190
  this.#persisted = await platform.persist().catch(() => false);
184
191
  // It only dials, so it writes no address into an invitation.
185
192
  const web = new WebCarry({ allowPrivate: this.#options.allowPrivate === true });
@@ -188,7 +195,7 @@ export class BrowserGround {
188
195
  for (const [faculty, made] of Object.entries(recipe.faculties?.() ?? {}))
189
196
  faculties[faculty] = await made;
190
197
  const bodies = {
191
- memory: joined({ indexeddb: ({ house }) => platform.memory(`${name}-house-${house}`) }, recipe.bodies?.memory),
198
+ memory: joined({ indexeddb: async ({ house }) => kept(await platform.memory(`${name}-house-${house}`)) }, recipe.bodies?.memory),
192
199
  classes: joined({
193
200
  origin: ({ args }) => {
194
201
  if (typeof args.at !== 'string')
@@ -209,6 +216,9 @@ export class BrowserGround {
209
216
  const ground = this.#ground;
210
217
  this.#ground = undefined;
211
218
  await ground?.close();
219
+ // Its IndexedDB connections go with it, so the next ground in this page opens on none of its.
220
+ for (const opened of this.#opened.splice(0))
221
+ opened.close?.();
212
222
  this.#release?.();
213
223
  this.#orphan(() => true);
214
224
  for (const resolve of this.#waiting)
@@ -19,6 +19,8 @@ export declare class LockedCustody implements Custody {
19
19
  constructor(shelf: Shelf, subtle?: SubtleCrypto);
20
20
  /** The custody of the origin, on a shelf in IndexedDB named `name`. */
21
21
  static open(name?: string, factory?: IDBFactory): Promise<LockedCustody>;
22
+ /** Its shelf let go, where the shelf holds a connection. */
23
+ close(): void;
22
24
  keys({ house }: {
23
25
  house: string;
24
26
  }): Promise<Keys>;
@@ -54,6 +54,10 @@ export class LockedCustody {
54
54
  static async open(name = 'nervur-custody', factory = indexedDB) {
55
55
  return new LockedCustody(await IndexedDbShelf.open(name, factory));
56
56
  }
57
+ /** Its shelf let go, where the shelf holds a connection. */
58
+ close() {
59
+ this.#shelf.close?.();
60
+ }
57
61
  keys({ house }) {
58
62
  if (!NAME.test(house))
59
63
  return Promise.reject(new TypeError(`no house is named ${house}`));
@@ -12,6 +12,8 @@ export class Rows {
12
12
  #placeNames = new Map();
13
13
  /** The last write holding each row, which the next one to hold it waits for. */
14
14
  #holds = new Map();
15
+ // How many times each row's cache has moved, so a read overtaken by a write never lands behind it.
16
+ #moves = new Map();
15
17
  #landed;
16
18
  constructor(memory, crypto, tools, names, key) {
17
19
  this.#memory = memory;
@@ -33,22 +35,36 @@ export class Rows {
33
35
  }
34
36
  return place;
35
37
  }
38
+ // A row's cache moved, by a write that landed or one refused: a read in flight across it is behind.
39
+ #moved(place) {
40
+ this.#moves.set(place, (this.#moves.get(place) ?? 0) + 1);
41
+ }
42
+ #forget(place) {
43
+ this.#moved(place);
44
+ this.#held.delete(place);
45
+ }
36
46
  async #read(place) {
37
- const cached = this.#held.get(place);
38
- if (cached !== undefined)
39
- return cached;
40
- const { entries, version } = await this.#memory.read({ place });
41
- const sealed = entries[ENTRY];
42
- let held = { row: null, text: null, version };
43
- if (sealed !== undefined) {
44
- const opened = await this.#crypto.open(this.#key, sealed.subarray(0, 12), sealed.subarray(12), this.#tools.utf8(place));
45
- const text = opened === null ? null : this.#tools.text(opened);
46
- if (text === null)
47
- throw new Error('a row does not open under these keys');
48
- held = { row: JSON.parse(text), text, version };
47
+ for (;;) {
48
+ const cached = this.#held.get(place);
49
+ if (cached !== undefined)
50
+ return cached;
51
+ const move = this.#moves.get(place) ?? 0;
52
+ const { entries, version } = await this.#memory.read({ place });
53
+ const sealed = entries[ENTRY];
54
+ let held = { row: null, text: null, version };
55
+ if (sealed !== undefined) {
56
+ const opened = await this.#crypto.open(this.#key, sealed.subarray(0, 12), sealed.subarray(12), this.#tools.utf8(place));
57
+ const text = opened === null ? null : this.#tools.text(opened);
58
+ if (text === null)
59
+ throw new Error('a row does not open under these keys');
60
+ held = { row: JSON.parse(text), text, version };
61
+ }
62
+ // A write that moved the row while this read was in flight wins, and this read looks again.
63
+ if ((this.#moves.get(place) ?? 0) !== move)
64
+ continue;
65
+ this.#held.set(place, held);
66
+ return held;
49
67
  }
50
- this.#held.set(place, held);
51
- return held;
52
68
  }
53
69
  /** A row as it stands, or `null`. */
54
70
  async get(place) {
@@ -152,10 +168,11 @@ export class Rows {
152
168
  }
153
169
  if (versions === null) {
154
170
  for (const place of Object.keys(expect))
155
- this.#held.delete(place);
171
+ this.#forget(place);
156
172
  return false;
157
173
  }
158
174
  for (const [place, held] of landed) {
175
+ this.#moved(place);
159
176
  this.#held.set(place, { ...held, version: versions[place] ?? null });
160
177
  this.#landed?.(place, held.row);
161
178
  }
package/dist/node/cli.js CHANGED
@@ -20,6 +20,7 @@ import { connect } from 'node:net';
20
20
  import { basename, join, resolve } from 'node:path';
21
21
  import { createInterface } from 'node:readline';
22
22
  import { fileURLToPath } from 'node:url';
23
+ import { handAt } from './hand.js';
23
24
  import { NodeGround } from './node-ground.js';
24
25
  import { notifyReady } from './notify.js';
25
26
  const USAGE = 'nervur up <folder> | service <folder> | [--at <socket>] help | ask <house> [--id <being>] [<method> [args]] | <faculty> [<method> [args]]';
@@ -127,7 +128,7 @@ const service = (folder) => {
127
128
  };
128
129
  const words = process.argv.slice(2);
129
130
  // The hand: named, or the one a ground in this folder serves.
130
- let at = process.env.NERVUR_HAND ?? (existsSync(join('state', 'hand')) ? join('state', 'hand') : undefined);
131
+ let at = process.env.NERVUR_HAND ?? (existsSync(process.platform === 'win32' ? 'state' : join('state', 'hand')) ? handAt('state') : undefined);
131
132
  if (words[0] === '--at') {
132
133
  at = words[1] ?? fail('--at names no socket');
133
134
  words.splice(0, 2);
@@ -5,6 +5,7 @@
5
5
  import { mkdir, open, readFile, rename } from 'node:fs/promises';
6
6
  import { dirname } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
+ import { syncFolder } from './sync-folder.js';
8
9
  const hex = (bytes) => Buffer.from(bytes).toString('hex');
9
10
  const unhex = (text) => new Uint8Array(Buffer.from(text, 'hex'));
10
11
  export class FileMemory {
@@ -80,12 +81,6 @@ export class FileMemory {
80
81
  await file.close();
81
82
  }
82
83
  await rename(temporary, this.#path);
83
- const directory = await open(folder, 'r');
84
- try {
85
- await directory.sync();
86
- }
87
- finally {
88
- await directory.close();
89
- }
84
+ await syncFolder(folder);
90
85
  }
91
86
  }
@@ -21,9 +21,16 @@ export interface HandRequest {
21
21
  /** The answer the owner holds, which makes a `readOnly` ask a watch. */
22
22
  readonly after?: Answer;
23
23
  }
24
+ /**
25
+ * Where a ground's hand stands for its state folder: a socket inside it,
26
+ * or on Windows a named pipe, which has no place in a folder, named for
27
+ * the folder's path.
28
+ */
29
+ export declare const handAt: (state: string, platform?: NodeJS.Platform) => string;
24
30
  /**
25
31
  * Serves `ask` at `path`: a house's own hand, or a ground's, which also
26
32
  * reads `house` and changes its record. A socket left there by a process
27
- * that is gone is replaced.
33
+ * that is gone is replaced. A named pipe on Windows is open to its user
34
+ * alone, as the operating system makes it, and leaves nothing behind.
28
35
  */
29
36
  export declare const serveHand: (ask: (request: HandRequest) => Promise<unknown>, path: string) => Promise<Hand>;
package/dist/node/hand.js CHANGED
@@ -1,8 +1,11 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
- // The hand on a local socket: one JSON object a line in, one answer a
3
- // line out, in order. The socket is readable by the ground's user alone.
2
+ // The hand on a local socket, or on Windows a named pipe: one JSON object
3
+ // a line in, one answer a line out, in order. It is open to the ground's
4
+ // user alone.
5
+ import { createHash } from 'node:crypto';
4
6
  import { chmod } from 'node:fs/promises';
5
7
  import { createServer } from 'node:net';
8
+ import { join, resolve as absolute } from 'node:path';
6
9
  import { createInterface } from 'node:readline';
7
10
  import { gone } from './gone.js';
8
11
  const object = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -46,16 +49,29 @@ const serve = (ask, socket) => {
46
49
  // A local socket's path fits the kernel's address: 103 bytes on macOS and
47
50
  // the BSDs, 107 on Linux, with the closing zero beyond.
48
51
  const LONGEST = process.platform === 'linux' ? 107 : 103;
52
+ const PIPE = /^\\\\[.?]\\pipe\\/;
53
+ /**
54
+ * Where a ground's hand stands for its state folder: a socket inside it,
55
+ * or on Windows a named pipe, which has no place in a folder, named for
56
+ * the folder's path.
57
+ */
58
+ export const handAt = (state, platform = process.platform) => platform === 'win32' ? `\\\\.\\pipe\\nervur-${createHash('sha256').update(absolute(state).toLowerCase()).digest('hex').slice(0, 16)}` : join(state, 'hand');
49
59
  /**
50
60
  * Serves `ask` at `path`: a house's own hand, or a ground's, which also
51
61
  * reads `house` and changes its record. A socket left there by a process
52
- * that is gone is replaced.
62
+ * that is gone is replaced. A named pipe on Windows is open to its user
63
+ * alone, as the operating system makes it, and leaves nothing behind.
53
64
  */
54
65
  export const serveHand = async (ask, path) => {
66
+ if (PIPE.test(path))
67
+ return listen(ask, path, false);
55
68
  const length = Buffer.byteLength(path);
56
69
  if (length > LONGEST)
57
70
  throw new Error(`a socket's path is at most ${LONGEST} bytes here, and ${path} is ${length}: serve the hand from a shorter folder`);
58
71
  await gone(path);
72
+ return listen(ask, path, true);
73
+ };
74
+ const listen = async (ask, path, file) => {
59
75
  const server = createServer((socket) => serve(ask, socket));
60
76
  const sockets = new Set();
61
77
  server.on('connection', (socket) => {
@@ -64,6 +80,8 @@ export const serveHand = async (ask, path) => {
64
80
  });
65
81
  await new Promise((resolve, reject) => {
66
82
  server.once('error', reject);
83
+ if (!file)
84
+ return void server.listen(path, () => resolve());
67
85
  // Closed to everyone from the first moment, then opened to the user.
68
86
  const mask = process.umask(0o177);
69
87
  try {
@@ -73,13 +91,15 @@ export const serveHand = async (ask, path) => {
73
91
  process.umask(mask);
74
92
  }
75
93
  });
76
- await chmod(path, 0o600);
94
+ if (file)
95
+ await chmod(path, 0o600);
77
96
  return {
78
97
  close: async () => {
79
98
  for (const socket of sockets)
80
99
  socket.destroy();
81
100
  await new Promise((resolve) => server.close(() => resolve()));
82
- await gone(path);
101
+ if (file)
102
+ await gone(path);
83
103
  },
84
104
  };
85
105
  };
@@ -9,6 +9,7 @@ import { createHash } from 'node:crypto';
9
9
  import { mkdir, open, readFile, rename, truncate } from 'node:fs/promises';
10
10
  import { dirname } from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
+ import { syncFolder } from './sync-folder.js';
12
13
  const GENESIS = { n: 0, hash: '0'.repeat(64) };
13
14
  const hash = (text) => createHash('sha256').update(text).digest('hex');
14
15
  const unhex = (text) => new Uint8Array(Buffer.from(text, 'hex'));
@@ -103,7 +104,7 @@ export class LedgerMemory {
103
104
  const temporary = `${this.#witness}.${process.pid}.tmp`;
104
105
  await synced(temporary, 'w', (handle) => handle.writeFile(JSON.stringify(head)));
105
106
  await rename(temporary, this.#witness);
106
- await synced(dirname(this.#witness), 'r', async () => undefined);
107
+ await syncFolder(dirname(this.#witness));
107
108
  }
108
109
  async read({ place }) {
109
110
  const held = (await this.#open()).places.get(place);
@@ -135,7 +136,7 @@ export class LedgerMemory {
135
136
  try {
136
137
  await synced(this.#path, 'a', (handle) => handle.appendFile(`${raw}\n`));
137
138
  if (line.n === 1)
138
- await synced(dirname(this.#path), 'r', async () => undefined);
139
+ await syncFolder(dirname(this.#path));
139
140
  if (this.#witness !== undefined)
140
141
  await this.#attest(head);
141
142
  }
@@ -20,7 +20,7 @@ import { WebCarry } from '../bodies/web-carry.js';
20
20
  import { Ground } from '../ground/ground.js';
21
21
  import { FolderCustody, KeychainCustody } from './custody.js';
22
22
  import { FolderClasses } from './folder-classes.js';
23
- import { serveHand } from './hand.js';
23
+ import { handAt, serveHand } from './hand.js';
24
24
  import { serveHttp } from './http.js';
25
25
  import { LedgerMemory } from './ledger-memory.js';
26
26
  import { takeLock } from './lock.js';
@@ -127,7 +127,7 @@ export class NodeGround {
127
127
  // 6. Hook: HTTP chains the web carry and every faculty's handler, and the hand takes its socket.
128
128
  if (httpPort !== undefined)
129
129
  http = await serveHttp({ port: httpPort, host: bind }, [web, ...ground.handlers]);
130
- const hand = env.NERVUR_HAND ?? join(state, 'hand');
130
+ const hand = env.NERVUR_HAND ?? handAt(state);
131
131
  const served = await serveHand((request) => ground.hand(request), hand);
132
132
  return new NodeGround({ ground, hand, tcp, lock, served, http });
133
133
  }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Makes a file's creation or renaming in `folder` durable. On POSIX the
3
+ * folder is synced. Windows opens no folder for syncing, and NTFS journals
4
+ * a creation and a rename itself, so there it does nothing.
5
+ */
6
+ export declare const syncFolder: (folder: string, platform?: NodeJS.Platform) => Promise<void>;
@@ -0,0 +1,18 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ import { open } from 'node:fs/promises';
3
+ /**
4
+ * Makes a file's creation or renaming in `folder` durable. On POSIX the
5
+ * folder is synced. Windows opens no folder for syncing, and NTFS journals
6
+ * a creation and a rename itself, so there it does nothing.
7
+ */
8
+ export const syncFolder = async (folder, platform = process.platform) => {
9
+ if (platform === 'win32')
10
+ return;
11
+ const handle = await open(folder, 'r');
12
+ try {
13
+ await handle.sync();
14
+ }
15
+ finally {
16
+ await handle.close();
17
+ }
18
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nervur",
3
- "version": "0.22.2-3",
3
+ "version": "0.22.2-5",
4
4
  "description": "Nervur's kit of Quo: write beings and faculties, and open the house that holds them on any ground.",
5
5
  "keywords": [
6
6
  "quo",
@@ -69,7 +69,7 @@
69
69
  "build": "rm -rf dist && tsc -p tsconfig.build.json",
70
70
  "prepack": "npm run build",
71
71
  "check": "tsc -p tsconfig.json && tsc -p tsconfig.pure.json && node test.mjs \"test/**/*.test.ts\"",
72
- "deep": "tsc -p tsconfig.deep.json && NERVUR_TEST_CAP=900000 node test.mjs \"deep/**/*.test.ts\"",
72
+ "deep": "tsc -p deep && NERVUR_TEST_CAP=900000 node test.mjs \"deep/**/*.test.ts\"",
73
73
  "prepublishOnly": "test \"$NERVUR_GATED\" = 1 || { echo 'a publish is /release, from the root, on the human'\"'\"'s word' >&2; exit 1; }"
74
74
  },
75
75
  "engines": {