OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-issues commits / 8ac9708

Add createWithId / commentWithId for v1 → v3 migration
Preserves explicit issue id (including id=0), status, createdAt, updatedAt, and commentId when migrating issues + comments from pear-git v1's MetadataBase into v3's per-repo HyperIssues trackers. - createWithId(id, opts): accepts explicit id + timestamps + status - commentWithId(issueId, commentId, opts): preserves v1 commentId format - Apply is now idempotent: second call with existing id/commentId is a no-op, preventing overwrites from replayed ops - Counter auto-advances past highest migrated id so post-migration create() doesn't collide 3 new tests cover: - id=0 + sparse ids + timestamp preservation - createWithId idempotency (overwrite rejected) - commentWithId preservation + idempotency 18/18 tests pass, 69/69 asserts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8ac97083947aca9137a6efde30579d79908a9e3c
by plankatron <fleeky@gmail.com> · 2026-04-20T14:44:13.000Z

Changed files (3) +149 -9

lib/hyper-issues.js +64 -0
……@@ -104,6 +104,42 @@ export class HyperIssues {
104104 return id
105105 }
106106
107 /**
108 * Create an issue with an explicit id — used for migration from v1.
109 * Idempotent: apply skips the write if an issue with this id already exists.
110 * Accepts status and createdAt/updatedAt to preserve v1 metadata verbatim.
111 *
112 * @param {number} id
113 * @param {object} options
114 * @param {string} options.title
115 * @param {string} [options.body]
116 * @param {string} options.author
117 * @param {string[]} [options.labels]
118 * @param {string[]} [options.images]
119 * @param {'open'|'closed'} [options.status='open']
120 * @param {number} [options.createdAt] - ms epoch
121 * @param {number} [options.updatedAt] - ms epoch
122 * @returns {Promise<number>} id (same as input, for symmetry with create())
123 */
124 async createWithId (id, options) {
125 if (typeof id !== 'number') throw new Error('createWithId requires numeric id')
126 const { title, body, author, labels, images, status, createdAt, updatedAt } = options
127 await this._room.append({
128 type: 'create-issue',
129 id,
130 title,
131 body: body || '',
132 userId: author,
133 labels: labels || [],
134 images: images || [],
135 status: status || 'open',
136 createdAt,
137 updatedAt,
138 timestamp: createdAt || Date.now()
139 })
140 return id
141 }
142
107143 /**
108144 * Update an issue (title, body, status, labels, images).
109145 * @param {number} id
……@@ -231,6 +267,34 @@ export class HyperIssues {
231267 return commentId
232268 }
233269
270 /**
271 * Create a comment with explicit commentId — migration use.
272 * Idempotent: apply skips if (issueId, commentId) exists.
273 * @param {number} issueId
274 * @param {string} commentId - preserves v1's commentId format
275 * @param {object} options
276 * @param {string} options.body
277 * @param {string} options.author
278 * @param {number} [options.createdAt] - ms epoch
279 * @param {boolean} [options.skipUpdate=true] - don't bump issue.updatedAt (migration runs many comments in bulk)
280 */
281 async commentWithId (issueId, commentId, options) {
282 if (typeof issueId !== 'number') throw new Error('commentWithId requires numeric issueId')
283 if (!commentId) throw new Error('commentWithId requires commentId')
284 const { body, author, createdAt, skipUpdate = true } = options
285 await this._room.append({
286 type: 'create-comment',
287 issueId,
288 commentId,
289 body,
290 userId: author,
291 createdAt,
292 timestamp: createdAt || Date.now(),
293 skipUpdate
294 })
295 return commentId
296 }
297
234298 async getComments (issueId) {
235299 await this._room.base.update()
236300 const comments = []
lib/schema.js +20 -9
……@@ -63,6 +63,11 @@ export const issuesSchema = jsonSchema({
6363 async apply (batch, op, host) {
6464 switch (op.type) {
6565 case 'create-issue': {
66 // Idempotent — skip if this id already exists. Prevents overwrites
67 // from replayed ops and supports migration with forceId (an incoming
68 // create-issue op with an explicit id won't clobber an existing issue).
69 const existing = await batch.get(`issue:${op.id}`)
70 if (existing) break
6671 await batch.put(`issue:${op.id}`, {
6772 id: op.id,
6873 title: op.title,
……@@ -71,8 +76,8 @@ export const issuesSchema = jsonSchema({
7176 status: op.status || 'open',
7277 labels: op.labels || [],
7378 images: op.images || [],
74 createdAt: op.timestamp || Date.now(),
75 updatedAt: op.timestamp || Date.now()
79 createdAt: op.createdAt || op.timestamp || Date.now(),
80 updatedAt: op.updatedAt || op.timestamp || Date.now()
7681 })
7782 // Bump the auto-increment counter
7883 const counter = await batch.get('counter:issues')
……@@ -98,16 +103,22 @@ export const issuesSchema = jsonSchema({
98103 }
99104
100105 case 'create-comment': {
101 await batch.put(`comment:${op.issueId}:${op.commentId}`, {
106 // Idempotent — skip if this (issueId, commentId) pair exists.
107 const key = `comment:${op.issueId}:${op.commentId}`
108 const existing = await batch.get(key)
109 if (existing) break
110 await batch.put(key, {
102111 body: op.body,
103112 author: op.userId,
104 createdAt: op.timestamp || Date.now()
113 createdAt: op.createdAt || op.timestamp || Date.now()
105114 })
106 // Bump the issue's updatedAt
107 const issueEntry = await batch.get(`issue:${op.issueId}`)
108 if (issueEntry) {
109 issueEntry.value.updatedAt = op.timestamp || Date.now()
110 await batch.put(`issue:${op.issueId}`, issueEntry.value)
115 // Bump the issue's updatedAt (skip if we're migrating and don't want to clobber)
116 if (!op.skipUpdate) {
117 const issueEntry = await batch.get(`issue:${op.issueId}`)
118 if (issueEntry) {
119 issueEntry.value.updatedAt = op.timestamp || Date.now()
120 await batch.put(`issue:${op.issueId}`, issueEntry.value)
121 }
111122 }
112123 break
113124 }
test/basic.test.js +65 -0
……@@ -314,3 +314,68 @@ test('two writers can both append after addWriter', async (t) => {
314314 try { await storeB.close() } catch {}
315315 })
316316})
317
318test('createWithId preserves id, status, and timestamps (migration path)', async (t) => {
319 const tracker = await makeTracker(t, 'migration-1')
320
321 await tracker.createWithId(0, {
322 title: 'issue zero',
323 body: 'v1 used id=0',
324 author: 'fleeky',
325 status: 'closed',
326 createdAt: 1733078495188,
327 updatedAt: 1734033780311
328 })
329 await tracker.createWithId(42, {
330 title: 'sparse id',
331 author: 'fleeky',
332 status: 'open',
333 createdAt: 1700000000000
334 })
335
336 const zero = await tracker.get(0)
337 t.is(zero.id, 0, 'id=0 preserved')
338 t.is(zero.status, 'closed')
339 t.is(zero.createdAt, 1733078495188, 'createdAt preserved')
340 t.is(zero.updatedAt, 1734033780311, 'updatedAt preserved')
341
342 const forty2 = await tracker.get(42)
343 t.is(forty2.id, 42)
344 t.is(forty2.createdAt, 1700000000000)
345
346 const newId = await tracker.create({ title: 'post-migration', author: 'fleeky' })
347 t.is(newId, 43, 'auto-increment picks up after highest migrated id')
348})
349
350test('createWithId is idempotent (second call skips overwrite)', async (t) => {
351 const tracker = await makeTracker(t, 'migration-2')
352
353 await tracker.createWithId(5, { title: 'first', author: 'fleeky', status: 'open' })
354 await tracker.createWithId(5, { title: 'OVERWRITE ATTEMPT', author: 'malicious', status: 'closed' })
355
356 const issue = await tracker.get(5)
357 t.is(issue.title, 'first', 'original preserved')
358 t.is(issue.author, 'fleeky')
359 t.is(issue.status, 'open')
360})
361
362test('commentWithId preserves commentId + createdAt (migration path)', async (t) => {
363 const tracker = await makeTracker(t, 'migration-3')
364
365 await tracker.createWithId(1, { title: 'with comments', author: 'fleeky' })
366 await tracker.commentWithId(1, 'v1-comment-id-abc', {
367 body: 'first comment',
368 author: 'fleeky',
369 createdAt: 1700000000000
370 })
371 await tracker.commentWithId(1, 'v1-comment-id-abc', {
372 body: 'OVERWRITE',
373 author: 'mal'
374 })
375
376 const comments = await tracker.getComments(1)
377 t.is(comments.length, 1)
378 t.is(comments[0].commentId, 'v1-comment-id-abc', 'commentId preserved')
379 t.is(comments[0].body, 'first comment', 'original body preserved')
380 t.is(comments[0].createdAt, 1700000000000, 'createdAt preserved')
381})