| … | … | @@ -104,6 +104,42 @@ export class HyperIssues { |
| 104 | 104 | return id |
| 105 | 105 | } |
| 106 | 106 | |
| 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 | ||
| 107 | 143 | /** |
| 108 | 144 | * Update an issue (title, body, status, labels, images). |
| 109 | 145 | * @param {number} id |
| … | … | @@ -231,6 +267,34 @@ export class HyperIssues { |
| 231 | 267 | return commentId |
| 232 | 268 | } |
| 233 | 269 | |
| 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 | ||
| 234 | 298 | async getComments (issueId) { |
| 235 | 299 | await this._room.base.update() |
| 236 | 300 | const comments = [] |
| … | … | @@ -63,6 +63,11 @@ export const issuesSchema = jsonSchema({ |
| 63 | 63 | async apply (batch, op, host) { |
| 64 | 64 | switch (op.type) { |
| 65 | 65 | 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 | |
| 66 | 71 | await batch.put(`issue:${op.id}`, { |
| 67 | 72 | id: op.id, |
| 68 | 73 | title: op.title, |
| … | … | @@ -71,8 +76,8 @@ export const issuesSchema = jsonSchema({ |
| 71 | 76 | status: op.status || 'open', |
| 72 | 77 | labels: op.labels || [], |
| 73 | 78 | 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() | |
| 76 | 81 | }) |
| 77 | 82 | // Bump the auto-increment counter |
| 78 | 83 | const counter = await batch.get('counter:issues') |
| … | … | @@ -98,16 +103,22 @@ export const issuesSchema = jsonSchema({ |
| 98 | 103 | } |
| 99 | 104 | |
| 100 | 105 | 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, { | |
| 102 | 111 | body: op.body, |
| 103 | 112 | author: op.userId, |
| 104 | createdAt: op.timestamp || Date.now() | |
| 113 | createdAt: op.createdAt || op.timestamp || Date.now() | |
| 105 | 114 | }) |
| 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 | } | |
| 111 | 122 | } |
| 112 | 123 | break |
| 113 | 124 | } |
| … | … | @@ -314,3 +314,68 @@ test('two writers can both append after addWriter', async (t) => { |
| 314 | 314 | try { await storeB.close() } catch {} |
| 315 | 315 | }) |
| 316 | 316 | }) |
| 317 | ||
| 318 | test('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 | ||
| 350 | test('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 | ||
| 362 | test('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 | }) |