sh3-server 0.11.6 → 0.11.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.
package/app/index.html CHANGED
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
7
  <title>SH3</title>
8
- <script type="module" crossorigin src="/assets/index-CaqIUyZM.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-DhXa8-tG.css">
8
+ <script type="module" crossorigin src="/assets/index-D9SiE54u.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-BXP4ACTa.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="app"></div>
@@ -64,6 +64,7 @@ export declare class TenantDocStore {
64
64
  syncState: 'synced' | 'pending';
65
65
  }>;
66
66
  delete(tenant: string, shardId: string, path: string): Promise<void>;
67
+ rename(tenant: string, shardId: string, oldPath: string, newPath: string): Promise<void>;
67
68
  applyFromPeer(tenant: string, input: ApplyFromPeerInput): Promise<ApplyResult>;
68
69
  listConflicts(tenant: string): Promise<ConflictRef[]>;
69
70
  readConflict(tenant: string, shardId: string, path: string): Promise<ConflictFile | null>;
@@ -6,12 +6,12 @@
6
6
  * conflict bucket on Mode B mismatch. Consumed by the docs HTTP router
7
7
  * and by the ServerShardContext.documents(tenant) API.
8
8
  */
9
- import { readFile, writeFile, mkdir, rm, readdir, stat } from 'node:fs/promises';
9
+ import { readFile, writeFile, mkdir, rm, readdir, stat, rename as fsRename } from 'node:fs/promises';
10
10
  import { readdirSync, existsSync } from 'node:fs';
11
11
  import { dirname, join, relative } from 'node:path';
12
12
  import { resolveSyncMode } from './policy.js';
13
13
  import { filterReservedMeta } from './reserved.js';
14
- import { readMeta, writeMeta } from './meta.js';
14
+ import { readMeta, writeMeta, deleteMeta } from './meta.js';
15
15
  export class TenantDocStore {
16
16
  #dataDir;
17
17
  #policy;
@@ -225,6 +225,50 @@ export class TenantDocStore {
225
225
  if (role === 'primary')
226
226
  await this.#tick.bump(tenant);
227
227
  }
228
+ // ---------- Mode A rename ----------
229
+ async rename(tenant, shardId, oldPath, newPath) {
230
+ const oldCp = this.#contentPath(tenant, shardId, oldPath);
231
+ const newCp = this.#contentPath(tenant, shardId, newPath);
232
+ if (!(await this.exists(tenant, shardId, oldPath))) {
233
+ throw new Error(`Document not found at ${oldPath}`);
234
+ }
235
+ if (await this.exists(tenant, shardId, newPath)) {
236
+ throw new Error(`Document already exists at ${newPath}`);
237
+ }
238
+ // Atomic content move (single fs.rename when on same volume).
239
+ await mkdir(dirname(newCp), { recursive: true });
240
+ await fsRename(oldCp, newCp);
241
+ // Migrate metadata: read old sidecar, bump version, write at new path,
242
+ // delete old sidecar.
243
+ const role = this.roles.get(tenant);
244
+ const prev = (await readMeta(this.#dataDir, tenant, shardId, oldPath)) ?? {};
245
+ const prevKnown = typeof prev?.lastKnownVersion === 'number' ? prev.lastKnownVersion : 0;
246
+ let version;
247
+ let lastKnownVersion;
248
+ let syncState;
249
+ if (role === 'primary') {
250
+ version = prevKnown + 1;
251
+ lastKnownVersion = version;
252
+ syncState = 'synced';
253
+ }
254
+ else {
255
+ version = prevKnown;
256
+ lastKnownVersion = prevKnown;
257
+ syncState = 'pending';
258
+ }
259
+ const policy = await this.#policy.get(tenant);
260
+ const syncMode = resolveSyncMode(policy, newPath);
261
+ await writeMeta(this.#dataDir, tenant, shardId, newPath, {
262
+ ...prev,
263
+ version,
264
+ lastKnownVersion,
265
+ syncMode,
266
+ syncState,
267
+ });
268
+ await deleteMeta(this.#dataDir, tenant, shardId, oldPath);
269
+ if (role === 'primary')
270
+ await this.#tick.bump(tenant);
271
+ }
228
272
  // ---------- Mode B — applyFromPeer ----------
229
273
  async applyFromPeer(tenant, input) {
230
274
  const { shardId, path, content, incomingVersion, expectedLocalVersion, origin } = input;
@@ -105,25 +105,50 @@ export function createDocsRouter(store, settings) {
105
105
  status: (await store.exists(tenant, shard, filePath)) ? 200 : 404,
106
106
  });
107
107
  });
108
- // Conflict resolve — must match before the generic PUT so the `/resolve`
109
- // suffix isn't captured as part of the file path.
108
+ // Conflict resolve and rename — must match before the generic PUT so the
109
+ // suffixes aren't captured as part of the file path.
110
110
  router.post('/:tenant/:shard/*', async (c) => {
111
111
  const { tenant, shard } = c.req.param();
112
112
  if (isReservedShardId(shard))
113
113
  return c.json({ error: 'Reserved shard id' }, 400);
114
114
  const rawPath = c.req.path.replace(`/api/docs/${tenant}/${shard}/`, '');
115
- if (!rawPath.endsWith('/resolve')) {
116
- return c.json({ error: 'Unknown docs POST endpoint' }, 404);
115
+ if (rawPath.endsWith('/resolve')) {
116
+ const filePath = rawPath.replace(/\/resolve$/, '');
117
+ if (!filePath)
118
+ return c.json({ error: 'Missing file path' }, 400);
119
+ const body = await c.req.json().catch(() => null);
120
+ if (!body || typeof body.choice === 'undefined') {
121
+ return c.json({ error: 'Body must include { choice }' }, 400);
122
+ }
123
+ await store.resolveConflict(tenant, shard, filePath, body.choice);
124
+ return c.json({ ok: true });
117
125
  }
118
- const filePath = rawPath.replace(/\/resolve$/, '');
119
- if (!filePath)
120
- return c.json({ error: 'Missing file path' }, 400);
121
- const body = await c.req.json().catch(() => null);
122
- if (!body || typeof body.choice === 'undefined') {
123
- return c.json({ error: 'Body must include { choice }' }, 400);
126
+ if (rawPath.endsWith('/rename')) {
127
+ const oldPath = rawPath.replace(/\/rename$/, '');
128
+ if (!oldPath)
129
+ return c.json({ error: 'Missing file path' }, 400);
130
+ const body = await c.req.json().catch(() => null);
131
+ if (!body || typeof body.to !== 'string' || body.to.length === 0) {
132
+ return c.json({ error: 'Body must include { to: string }' }, 400);
133
+ }
134
+ if (body.to === oldPath) {
135
+ return c.json({ error: 'Rename target must differ from source' }, 400);
136
+ }
137
+ try {
138
+ await store.rename(tenant, shard, oldPath, body.to);
139
+ }
140
+ catch (err) {
141
+ const msg = String(err?.message ?? err);
142
+ if (/not found/i.test(msg))
143
+ return c.json({ error: msg }, 404);
144
+ if (/already exists/i.test(msg))
145
+ return c.json({ error: msg }, 409);
146
+ throw err;
147
+ }
148
+ const meta = await store.readMeta(tenant, shard, body.to);
149
+ return c.json({ ok: true, version: meta?.version });
124
150
  }
125
- await store.resolveConflict(tenant, shard, filePath, body.choice);
126
- return c.json({ ok: true });
151
+ return c.json({ error: 'Unknown docs POST endpoint' }, 404);
127
152
  });
128
153
  // Write
129
154
  router.put('/:tenant/:shard/*', async (c) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh3-server",
3
- "version": "0.11.6",
3
+ "version": "0.11.7",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sh3-server": "dist/cli.js"