@youzan-cloud/zan-saas-cli 0.1.0-beta.4 → 0.1.0-beta.7

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.
@@ -10,12 +10,31 @@ const {
10
10
  const { withSystemSchema } = require('./contracts');
11
11
  const { createPrinter, formatOption } = require('./printer');
12
12
  const { shopSelectionHint } = require('./shop-guidance');
13
+ const sessions = require('../../identity/session-store');
14
+ const locations = require('../../identity/shop-location');
15
+ const { snapshotApiIdentity } = require('../../transport/api');
16
+ const { withFileLock } = require('../../auth/credential-store');
13
17
 
14
18
  const idPattern = /^[1-9]\d*$/;
15
- const orgLabels = { hq: '总部', shop: '单店' };
19
+ const orgLabels = { hq: '总部', shop: '非总部组织' };
16
20
  const roleLabel = role => account.shopRoles[role] || '未知类型';
17
21
  const envNote = '环境变量 ZAN_SAAS_CONTEXT 已设置,业务命令仍以它为准。';
18
22
 
23
+ function sharedResult(value, savedEntry) {
24
+ const context = value.context;
25
+ const same = context && savedEntry && ['userId', 'hq', 'shopId', 'orgType']
26
+ .every(field => context[field] === savedEntry.context[field]);
27
+ return { ...value,
28
+ binding: { mode: 'shared', sessionRef: null, state: savedEntry ? 'ready' : 'needs_selection',
29
+ revision: null, origin: savedEntry ? 'explicit' : 'none',
30
+ initializedAt: null, selectedAt: savedEntry?.savedAt ?? null },
31
+ selection: context ? locations.selection(context, {
32
+ tenantName: same && context.hq === context.shopId ? savedEntry.shopName : null,
33
+ unitName: same ? savedEntry.shopName : null
34
+ }) : null
35
+ };
36
+ }
37
+
19
38
  function positiveInteger(name, max = Number.MAX_SAFE_INTEGER) {
20
39
  return value => {
21
40
  if (!idPattern.test(value) || Number(value) > max) {
@@ -65,10 +84,11 @@ async function checkStaffStatuses(items, env, concurrency = 5) {
65
84
  // Hides the shops where the user's staff account is disabled, unless --all; total stays the server's count.
66
85
  async function listShops(options = {}, env = process.env) {
67
86
  const name = typeof options.name === 'string' ? options.name.trim() : '';
87
+ const { environment } = await snapshotApiIdentity(env);
68
88
  const page = await account.listShops({
69
89
  ...(name ? { name } : {}), pageNum: options.pageNo ?? 1, pageSize: options.pageSize ?? 20,
70
90
  lineOfBusinesses: options.lineOfBusiness ?? account.defaultLineOfBusinesses
71
- }, env);
91
+ }, environment);
72
92
  // Staff filtering may hide the entire page; only the server's unfiltered total determines continuation.
73
93
  const hasMore = page.total === null ? null :
74
94
  BigInt(page.pageNum) * BigInt(page.pageSize) < BigInt(page.total);
@@ -77,7 +97,7 @@ async function listShops(options = {}, env = process.env) {
77
97
  windowLimitReached: hasMore !== false && page.pageNum === Number.MAX_SAFE_INTEGER });
78
98
  if (options.all) return { ...page, totalScope: 'before_staff_filter', hidden: 0,
79
99
  hiddenReason: null, nextStep: null, unchecked: [], uncheckedReason: null };
80
- const checks = await checkStaffStatuses(page.items, env);
100
+ const checks = await checkStaffStatuses(page.items, environment);
81
101
  const items = page.items.filter((_, index) => checks[index].status !== account.staffStatuses.disabled);
82
102
  const unchecked = page.items.filter((_, index) => checks[index].error).map(item => item.kdtId);
83
103
  const failed = checks.find(check => check.error);
@@ -92,12 +112,15 @@ async function listShops(options = {}, env = process.env) {
92
112
  }
93
113
 
94
114
  // Confirms the shop through the user's own shop list before saving it as the context.
95
- async function useShop(kdtId, env = process.env) {
115
+ async function useShop(kdtId, env = process.env, options = {}, explicitContext) {
116
+ if (sessions.sessionId(env)) return locations.select(kdtId, options, env, explicitContext);
117
+ if (options.sessionOnly) throw sessions.fail('session_missing', '--session-only 需要宿主会话标识。');
96
118
  if (typeof kdtId !== 'string' || !idPattern.test(kdtId) || !Number.isSafeInteger(Number(kdtId))) {
97
119
  throw new Error('kdtId 必须是正整数店铺 ID。');
98
120
  }
99
- const user = await account.whoami(env);
100
- const page = await account.listShops({ includeKdtIds: [kdtId], pageNum: 1, pageSize: 1 }, env);
121
+ const actor = await locations.identify(env);
122
+ const user = actor.user;
123
+ const page = await account.listShops({ includeKdtIds: [kdtId], pageNum: 1, pageSize: 1 }, actor.pinned);
101
124
  const shop = page.items.find(item => item.kdtId === kdtId);
102
125
  if (!shop) {
103
126
  throw new Error(`当前账号下没有可选店铺 ${kdtId}。执行 zan-saas shop list 查看可选店铺。`);
@@ -106,28 +129,54 @@ async function useShop(kdtId, env = process.env) {
106
129
  throw new Error(`店铺 ${kdtId} 是${roleLabel(shop.shopRole)},暂时只能选择总部或单店。`);
107
130
  }
108
131
  // A failed check does not block: the business gateway still verifies the user against the shop.
109
- const status = await account.staffStatus(kdtId, env).catch(() => null);
132
+ const status = await account.staffStatus(kdtId, actor.pinned).catch(() => null);
110
133
  if (status === account.staffStatuses.disabled) {
111
134
  throw new Error(`你在店铺 ${kdtId} 的员工账号已停用,不能选择。`);
112
135
  }
113
- const context = saveContext({ userId: user.userId, hq: kdtId, shopId: kdtId, orgType: shop.orgType },
114
- { shopName: shop.shopName }, env);
136
+ const context = await withFileLock(env, async () => {
137
+ locations.assertIdentity(actor, env);
138
+ return saveContext({ userId: user.userId, hq: kdtId, shopId: kdtId, orgType: shop.orgType },
139
+ { shopName: shop.shopName }, env);
140
+ });
115
141
  return { context, shopName: shop.shopName, overriddenByEnv: Boolean(env.ZAN_SAAS_CONTEXT) };
116
142
  }
117
143
 
118
- function currentShop(env = process.env) {
144
+ function currentShop(env = process.env, explicitContext) {
145
+ if (sessions.sessionId(env)) {
146
+ const snapshot = sessions.snapshot(env);
147
+ const location = snapshot.binding?.location;
148
+ const saved = location ? { context: location.context, shopName: locations.displayName(location) } : null;
149
+ const source = explicitContext !== undefined ? 'program' :
150
+ env.ZAN_SAAS_CONTEXT ? 'env' : saved ? 'saved' : 'missing';
151
+ const state = readContext(source === 'program' ? explicitContext :
152
+ source === 'env' ? env.ZAN_SAAS_CONTEXT : location?.context);
153
+ const context = state.context ?? null;
154
+ return { source, status: state.status, context, saved, requestShopId: requestShopIdOf(env),
155
+ binding: sessions.publicBinding(snapshot),
156
+ selection: context ? (location && ['userId', 'hq', 'shopId', 'orgType']
157
+ .every(field => context[field] === location.context[field])
158
+ ? location.selection : locations.selection(context)) : null };
159
+ }
119
160
  const savedEntry = readSavedContext(env);
120
161
  const saved = savedEntry ? { context: savedEntry.context, shopName: savedEntry.shopName } : null;
121
162
  const requestShopId = requestShopIdOf(env);
122
- if (env.ZAN_SAAS_CONTEXT) {
123
- const state = readContext(env.ZAN_SAAS_CONTEXT);
124
- return { source: 'env', status: state.status, context: state.context ?? null, saved, requestShopId };
163
+ if (explicitContext !== undefined || env.ZAN_SAAS_CONTEXT) {
164
+ const state = readContext(explicitContext !== undefined ? explicitContext : env.ZAN_SAAS_CONTEXT);
165
+ return sharedResult({ source: explicitContext !== undefined ? 'program' : 'env',
166
+ status: state.status, context: state.context ?? null, saved, requestShopId }, savedEntry);
125
167
  }
126
- return saved ? { source: 'saved', status: 'ready', context: saved.context, saved, requestShopId } :
127
- { source: 'missing', status: 'missing', context: null, saved: null, requestShopId };
168
+ return sharedResult(saved ? { source: 'saved', status: 'ready', context: saved.context, saved, requestShopId } :
169
+ { source: 'missing', status: 'missing', context: null, saved: null, requestShopId }, savedEntry);
128
170
  }
129
171
 
130
- function clearShop(env = process.env) {
172
+ function clearShop(env = process.env, options = {}, explicitContext) {
173
+ if (sessions.sessionId(env)) {
174
+ locations.rejectOverride(env, explicitContext);
175
+ const effect = sessions.clear(options, env);
176
+ return { ...effect, server: oauthConfig(env).server,
177
+ binding: sessions.publicBinding(sessions.snapshot(env)) };
178
+ }
179
+ if (options.recent) throw sessions.fail('session_missing', '--recent 需要宿主会话标识。');
131
180
  return { cleared: deleteContext(env), server: oauthConfig(env).server };
132
181
  }
133
182
 
@@ -154,42 +203,71 @@ const text = {
154
203
  if (value.items.some(item => item.orgType)) lines.push(shopSelectionHint);
155
204
  return lines.join('\n');
156
205
  },
157
- use: value => `${value.overriddenByEnv ? '已保存店铺' : '已切换到店铺'} ${describe(value.context, value.shopName)}。` +
206
+ use: value => value.effect
207
+ ? `已切换到店铺 ${locations.displayName(value) || value.context.shopId}。\n` +
208
+ `${value.effect.recentChanged ? '已更新供新会话继承的位置。' : '其他会话保持各自位置。'}\n确认当前店铺:zan-saas shop current`
209
+ : `${value.overriddenByEnv ? '已保存店铺' : '已切换到店铺'} ${describe(value.context, value.shopName)}。` +
158
210
  (value.overriddenByEnv ? `\n${envNote}` : '') +
159
211
  '\n确认当前店铺:zan-saas shop current',
160
212
  current: value => {
213
+ if (value.binding?.mode === 'session') {
214
+ const name = value.selection && locations.displayName({ selection: value.selection });
215
+ const states = { uninitialized: '尚未初始化', identity_pending: '当前账号尚未核实',
216
+ needs_selection: '尚未选择店铺', cleared: '已清除选择', invalid: '记录无效', ready: '就绪' };
217
+ return `${value.context ? `当前店铺:${name || value.context.shopId}` : '当前没有有效店铺。'}\n` +
218
+ `会话状态:${states[value.binding.state]};位置来源:${value.source}。` +
219
+ (value.source === 'env' || value.source === 'program' ? '\n显式上下文覆盖保存的位置。' : '') +
220
+ (value.requestShopId ? `\n请求店铺头覆盖:${value.requestShopId},具体目标须按命令核验。` : '');
221
+ }
161
222
  const requestNote = value.requestShopId
162
223
  ? `\n环境变量 ZAN_SAAS_KDT_ID 设置为 ${value.requestShopId};具体请求店铺以所用命令和目标参数为准。`
163
224
  : '';
164
- if (value.source === 'env') {
225
+ if (value.source === 'env' || value.source === 'program') {
165
226
  const head = value.status === 'ready'
166
- ? `当前上下文来自环境变量 ZAN_SAAS_CONTEXT(kdtId ${value.context.shopId},${orgLabels[value.context.orgType]})。`
227
+ ? `当前上下文来自${value.source === 'env' ? '环境变量 ZAN_SAAS_CONTEXT' : '程序注入'}(kdtId ${value.context.shopId},${orgLabels[value.context.orgType]})。`
167
228
  : '环境变量 ZAN_SAAS_CONTEXT 无效,业务命令会拒绝执行。';
168
229
  return `${value.saved ? `${head}\n已保存的店铺 ${describe(value.saved.context, value.saved.shopName)} 被它覆盖。` : head}${requestNote}`;
169
230
  }
170
231
  if (value.source === 'saved') {
171
- return `当前店铺:${describe(value.context, value.saved.shopName)}${requestNote}\n${shopSelectionHint}`;
232
+ return `当前店铺:${describe(value.context, value.saved.shopName)}${requestNote}\n${shopSelectionHint}\n当前使用本机共享选店,其他会话切店可能影响后续调用。`;
172
233
  }
173
234
  return `未选择店铺。${requestNote}\n${shopSelectionHint}`;
174
235
  },
175
- clear: value => (value.cleared ? '已清除保存的店铺。' : '没有保存的店铺。')
236
+ clear: value => (value.cleared ? '已清除保存的店铺。' : '没有保存的店铺。') +
237
+ (value.binding ? '\n本会话保留清除状态,不会自动继承最近位置。' : ''),
238
+ init: value => value.context
239
+ ? `本会话当前店铺:${locations.displayName(value) || value.context.shopId}。`
240
+ : '本会话已初始化,请明确选择店铺。',
241
+ resolve: value => `本次目标:${locations.displayName(value) || value.context.shopId}。\n当前位置和最近位置均未改变。`
176
242
  };
177
243
  const printer = createPrinter(text);
178
244
 
179
245
  const defaults = {
180
- list: options => listShops(options), use: kdtId => useShop(kdtId), current: () => currentShop(),
181
- clear: () => clearShop(), currentShopId: () => shopIdOf(process.env)
246
+ list: options => listShops(options), use: (kdtId, options) => useShop(kdtId, process.env, options),
247
+ current: () => currentShop(), clear: options => clearShop(process.env, options),
248
+ init: options => locations.initialize(options),
249
+ unit: (kdtId, options) => locations.select(kdtId, { ...options, unit: true }),
250
+ resolve: (kdtId, options) => locations.resolve(kdtId, options),
251
+ currentShopId: () => shopIdOf(process.env)
182
252
  };
183
253
 
184
- function createShopCommand(impl = defaults) {
254
+ function createShopCommand(impl, { context } = {}) {
255
+ impl ||= { ...defaults,
256
+ use: (kdtId, options) => useShop(kdtId, process.env, options, context),
257
+ unit: (kdtId, options) => locations.select(kdtId, { ...options, unit: true }, process.env, context),
258
+ current: () => currentShop(process.env, context),
259
+ clear: options => clearShop(process.env, options, context) };
185
260
  const shop = new Command('shop').description('查看可用店铺并设置当前店铺')
186
261
  .addHelpText('after', `
187
- 登录成功后,首次选店或切换店铺使用同一流程,无需重新登录:
262
+ 登录成功后查看当前位置,再按本次意图选路,无需重新登录:
263
+ 会话首次使用:zan-saas shop session init;恢复时加 --resume
188
264
  1. 查看可选店铺:zan-saas shop list
189
265
  2. 用户选定目标后:zan-saas shop use <kdtId>
190
266
  3. 确认当前店铺:zan-saas shop current
191
267
  将 <kdtId> 替换为目标店铺 ID;店铺较多时可用 shop list --name <keyword> 查找。
192
268
  浏览器中切换店铺不会改变 CLI 保存的选择。
269
+ 连锁内切换组织:zan-saas shop unit use <kdtId>
270
+ 临时查询其他店铺:zan-saas shop resolve <kdtId>,使用返回的 context 执行本次任务。
193
271
  ZAN_SAAS_CONTEXT 会覆盖保存的店铺,切换后以 shop current 显示的实际来源为准。
194
272
  `);
195
273
  withSystemSchema(shop.command('list')
@@ -216,21 +294,44 @@ windowLimitReached 为 true 表示达到 CLI 可表示的页码上限,请缩
216
294
  withSystemSchema(shop.command('use')
217
295
  .description('选择当前用户的店铺并保存为上下文')
218
296
  .argument('<kdtId>', 'shop list 返回的总部或单店 kdtId')
297
+ .option('--session-only', '只改变本会话,不更新新会话继承的位置')
219
298
  .addOption(formatOption())
220
299
  .addHelpText('after', `
221
300
  先确认店铺属于当前账号,再把 userId、hq、shopId、orgType 保存到本地;业务命令随后用它选择店铺。
222
301
  已有有效上下文时沿用当前店铺;用户按店名明确选择且唯一匹配,或确认候选后,使用对应 kdtId 执行;本地上下文不是业务接口授权证明。
223
302
  环境变量 ZAN_SAAS_CONTEXT 设置时优先于保存的店铺。
224
303
  `)
225
- .action(async (kdtId, options) => { printer('use', options.format)(await impl.use(kdtId)); }), 'shop.use');
304
+ .action(async (kdtId, options) => { printer('use', options.format)(await impl.use(kdtId, options)); }), 'shop.use');
226
305
  withSystemSchema(shop.command('current')
227
306
  .description('查看业务命令使用的店铺上下文,不访问网络')
228
307
  .addOption(formatOption())
229
308
  .action(options => { printer('current', options.format)(impl.current()); }), 'shop.current');
230
309
  withSystemSchema(shop.command('clear')
231
310
  .description('清除本机保存的店铺上下文,不访问网络')
311
+ .option('--recent', '同时清除供新会话继承的位置;需要会话模式')
312
+ .addOption(formatOption())
313
+ .action(options => { printer('clear', options.format)(impl.clear(options)); }), 'shop.clear');
314
+ const session = shop.command('session').description('初始化或恢复宿主会话的选店记录');
315
+ withSystemSchema(session.command('init')
316
+ .description('首次继承最近位置;已有会话保持自己的选择')
317
+ .option('--resume', '恢复原会话;记录缺失时失败,不重新继承')
318
+ .addOption(formatOption())
319
+ .action(async options => printer('init', options.format)(await impl.init(options))), 'shop.session.init');
320
+ const unit = shop.command('unit').description('切换当前连锁内的操作组织');
321
+ withSystemSchema(unit.command('use')
322
+ .description('保留当前连锁根,切换到已核实的门店、网店或总部')
323
+ .argument('<kdtId>', '当前连锁内目标组织的正整数 ID')
324
+ .option('--session-only', '只改变本会话,不更新新会话继承的位置')
325
+ .addOption(formatOption())
326
+ .action(async (kdtId, options) => printer('use', options.format)(await impl.unit(kdtId, options))),
327
+ 'shop.unit.use');
328
+ withSystemSchema(shop.command('resolve')
329
+ .description('核实本次临时目标并返回上下文,不改变保存的位置')
330
+ .argument('<kdtId>', '目标店铺根或组织的正整数 ID')
331
+ .option('--root-kdt-id <id>', '目标所属连锁根 ID;省略时目标须为账号层根')
232
332
  .addOption(formatOption())
233
- .action(options => { printer('clear', options.format)(impl.clear()); }), 'shop.clear');
333
+ .action(async (kdtId, options) => printer('resolve', options.format)(await impl.resolve(kdtId, options))),
334
+ 'shop.resolve');
234
335
  return shop;
235
336
  }
236
337
 
@@ -1,7 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  const { oauthConfig } = require('../auth/config');
4
- const { readStore, storePath, writeStore } = require('../auth/credential-store');
4
+ const { storePath } = require('../auth/credential-store');
5
+ const { readFileStore, updateFileStore } = require('./file-store');
5
6
  const { createContext, readContext } = require('./context');
6
7
 
7
8
  const fileName = 'context.json';
@@ -12,7 +13,7 @@ function contextPath(env = process.env) {
12
13
 
13
14
  // The selected shop is kept per oauth origin, like the credential it was chosen with.
14
15
  function readSavedContext(env = process.env) {
15
- const entry = readStore(fileName, env)[oauthConfig(env).server];
16
+ const entry = readFileStore(contextPath(env))[oauthConfig(env).server];
16
17
  const state = readContext(entry?.context);
17
18
  if (state.status !== 'ready') return null;
18
19
  return Object.freeze({
@@ -25,24 +26,27 @@ function readSavedContext(env = process.env) {
25
26
  function saveContext(input, { shopName } = {}, env = process.env) {
26
27
  const context = createContext(input);
27
28
  const { server } = oauthConfig(env);
28
- const data = readStore(fileName, env);
29
- data[server] = { context, ...(shopName ? { shopName } : {}), savedAt: new Date().toISOString() };
30
- writeStore(fileName, data, env);
29
+ updateFileStore(contextPath(env), data => {
30
+ data[server] = { context, ...(shopName ? { shopName } : {}), savedAt: new Date().toISOString() };
31
+ });
31
32
  return context;
32
33
  }
33
34
 
34
35
  function deleteContext(env = process.env) {
35
36
  const { server } = oauthConfig(env);
36
- const data = readStore(fileName, env);
37
- if (!Object.hasOwn(data, server)) return false;
38
- delete data[server];
39
- writeStore(fileName, data, env);
40
- return true;
37
+ return updateFileStore(contextPath(env), data => {
38
+ if (!Object.hasOwn(data, server)) return false;
39
+ delete data[server];
40
+ return true;
41
+ });
41
42
  }
42
43
 
43
44
  // ZAN_SAAS_CONTEXT wins, even when invalid; otherwise the shop chosen with `shop use`.
44
45
  function contextInput(env = process.env) {
45
46
  if (env.ZAN_SAAS_CONTEXT) return env.ZAN_SAAS_CONTEXT;
47
+ if (env.ZAN_SAAS_SESSION_ID !== undefined) {
48
+ return require('./session-store').sessionContext(env);
49
+ }
46
50
  try {
47
51
  return readSavedContext(env)?.context;
48
52
  } catch {
@@ -0,0 +1,147 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { createHash, randomUUID } = require('node:crypto');
6
+
7
+ const pause = new Int32Array(new SharedArrayBuffer(4));
8
+ const digest = text => createHash('sha256').update(text).digest('hex');
9
+
10
+ function reclaim(lock, previous) {
11
+ const guard = `${lock}.reclaim`;
12
+ let fd;
13
+ try { fd = fs.openSync(guard, 'wx', 0o600); }
14
+ catch (error) {
15
+ if (error.code === 'EEXIST') return false;
16
+ throw storageError();
17
+ }
18
+ try {
19
+ if (readText(lock) !== previous) return false;
20
+ fs.unlinkSync(lock);
21
+ return true;
22
+ } finally {
23
+ fs.closeSync(fd);
24
+ fs.unlinkSync(guard);
25
+ }
26
+ }
27
+
28
+ function storageError(reason = 'context_store_invalid') {
29
+ const error = new Error(reason === 'context_lock_timeout'
30
+ ? '选店记录正在被其他调用更新,请稍后核对当前位置。'
31
+ : '选店记录损坏或无法读取,请核对本机上下文文件;未回退到其他店铺。');
32
+ error.diagnosticReason = reason;
33
+ return error;
34
+ }
35
+
36
+ function readText(filename) {
37
+ try {
38
+ const fd = fs.openSync(filename, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
39
+ try {
40
+ if (!fs.fstatSync(fd).isFile()) throw storageError();
41
+ return fs.readFileSync(fd, 'utf8');
42
+ } finally { fs.closeSync(fd); }
43
+ } catch (error) {
44
+ if (error.code === 'ENOENT') return null;
45
+ throw storageError();
46
+ }
47
+ }
48
+
49
+ function withLock(filename, action) {
50
+ const lock = `${filename}.lock`;
51
+ const started = Date.now();
52
+ const owner = JSON.stringify({ pid: process.pid, nonce: randomUUID() });
53
+ let fd;
54
+ for (;;) {
55
+ try {
56
+ fd = fs.openSync(lock, 'wx', 0o600);
57
+ fs.writeFileSync(fd, owner);
58
+ break;
59
+ } catch (error) {
60
+ if (error.code !== 'EEXIST') throw storageError();
61
+ const previous = readText(lock);
62
+ try {
63
+ const { pid } = JSON.parse(previous);
64
+ if (Number.isSafeInteger(pid) && pid > 0) {
65
+ try { process.kill(pid, 0); }
66
+ catch (dead) {
67
+ if (dead.code === 'ESRCH' && readText(lock) === previous) {
68
+ if (reclaim(lock, previous)) continue;
69
+ }
70
+ }
71
+ }
72
+ } catch { /* An incomplete lock is never stolen from a live writer. */ }
73
+ if (Date.now() - started >= 5000) throw storageError('context_lock_timeout');
74
+ Atomics.wait(pause, 0, 0, 10);
75
+ }
76
+ }
77
+ try { return action(); }
78
+ finally {
79
+ fs.closeSync(fd);
80
+ if (readText(lock) === owner) fs.unlinkSync(lock);
81
+ }
82
+ }
83
+
84
+ function parse(text) {
85
+ try {
86
+ const value = JSON.parse(text);
87
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw storageError();
88
+ return value;
89
+ } catch { throw storageError(); }
90
+ }
91
+
92
+ function readUnlocked(filename) {
93
+ const pending = readText(`${filename}.journal`);
94
+ if (pending?.endsWith('\n')) {
95
+ const record = parse(pending);
96
+ if (typeof record.payload !== 'string' || digest(record.payload) !== record.sha256) {
97
+ throw storageError();
98
+ }
99
+ return parse(record.payload);
100
+ }
101
+ const text = readText(filename);
102
+ return text === null ? {} : parse(text);
103
+ }
104
+
105
+ // Keep the existing inode: permissions granted to a file remain attached to that file.
106
+ function rewrite(filename, text) {
107
+ let fd;
108
+ try {
109
+ fd = fs.openSync(filename, fs.constants.O_RDWR | fs.constants.O_CREAT |
110
+ (fs.constants.O_NOFOLLOW || 0), 0o600);
111
+ if (!fs.fstatSync(fd).isFile()) throw storageError();
112
+ const bytes = Buffer.from(text);
113
+ let offset = 0;
114
+ while (offset < bytes.length) {
115
+ offset += fs.writeSync(fd, bytes, offset, bytes.length - offset, offset);
116
+ }
117
+ fs.ftruncateSync(fd, bytes.length);
118
+ fs.fsyncSync(fd);
119
+ fs.fchmodSync(fd, 0o600);
120
+ } finally { if (fd !== undefined) fs.closeSync(fd); }
121
+ }
122
+
123
+ function readFileStore(filename) {
124
+ if (!fs.existsSync(path.dirname(filename))) return {};
125
+ return withLock(filename, () => readUnlocked(filename));
126
+ }
127
+
128
+ function updateFileStore(filename, update) {
129
+ fs.mkdirSync(path.dirname(filename), { recursive: true, mode: 0o700 });
130
+ return withLock(filename, () => {
131
+ const data = readUnlocked(filename);
132
+ const result = update(data);
133
+ const payload = `${JSON.stringify(data, null, 2)}\n`;
134
+ // Finish a previously committed rewrite before reusing its journal.
135
+ if (readText(`${filename}.journal`)?.endsWith('\n')) {
136
+ rewrite(filename, `${JSON.stringify(readUnlocked(filename), null, 2)}\n`);
137
+ }
138
+ rewrite(`${filename}.journal`, '');
139
+ // A durable redo record is the commit point. Readers can use it after an interrupted rewrite.
140
+ rewrite(`${filename}.journal`, `${JSON.stringify({ payload, sha256: digest(payload) })}\n`);
141
+ rewrite(filename, payload);
142
+ rewrite(`${filename}.journal`, '');
143
+ return result;
144
+ });
145
+ }
146
+
147
+ module.exports = { digest, readFileStore, updateFileStore, storageError };