@@ -3,13 +3,14 @@ export * as EventV2 from "./event"
33import { Cause , Context , Effect , FiberSet , Layer , Option , PubSub , Schema , Stream } from "effect"
44import { Event } from "@opencode-ai/schema/event"
55import type { Data , Definition , Payload } from "@opencode-ai/schema/event"
6- import { and , asc , eq , gt } from "drizzle-orm"
6+ import { and , asc , desc , eq , gt } from "drizzle-orm"
77import { Database } from "./database/database"
88import { EventSequenceTable , EventTable } from "./event/sql"
99import { Location } from "./location"
1010import { LayerNode } from "./effect/layer-node"
1111import { isDeepStrictEqual } from "node:util"
1212import { Durable } from "@opencode-ai/schema/durable-event-manifest"
13+ import { Hash } from "./util/hash"
1314
1415export const ID = Event . ID
1516export type ID = import ( "@opencode-ai/schema/event" ) . ID
@@ -227,6 +228,30 @@ export const layerWith = (options?: LayerOptions) =>
227228 if ( input && row ?. ownerID && row . ownerID !== input . ownerID ) {
228229 return undefined
229230 }
231+ const dataHash = Hash . sha256 ( JSON . stringify ( encoded ) )
232+ if ( ! input ) {
233+ // Idempotency gate (#523): a fresh append that byte-for-byte repeats
234+ // this aggregate's latest same-type event carries zero information
235+ // delta. Skip it entirely — no seq consumed, no projectors, no commit
236+ // hook, no durable wake — so the persisted sequence stays dense and
237+ // both the replayAll contiguity check and gt(seq, after) readers are
238+ // unaffected. Replay appends (input) keep their exact-seq contract,
239+ // and legacy rows carry a NULL hash so they never match.
240+ const previous = yield * db
241+ . select ( { dataHash : EventTable . data_hash } )
242+ . from ( EventTable )
243+ . where (
244+ and (
245+ eq ( EventTable . aggregate_id , aggregateID ) ,
246+ eq ( EventTable . type , versionedType ( definition . type , durable . version ) ) ,
247+ ) ,
248+ )
249+ . orderBy ( desc ( EventTable . seq ) )
250+ . limit ( 1 )
251+ . get ( )
252+ . pipe ( Effect . orDie )
253+ if ( previous && previous . dataHash === dataHash ) return undefined
254+ }
230255 const seq = input ?. seq ?? latest + 1
231256 if ( input && seq !== latest + 1 ) {
232257 yield * Effect . die (
@@ -278,6 +303,7 @@ export const layerWith = (options?: LayerOptions) =>
278303 seq,
279304 type : versionedType ( definition . type , durable . version ) ,
280305 data : encoded ,
306+ data_hash : dataHash ,
281307 } ,
282308 ] )
283309 . run ( )
@@ -479,7 +505,9 @@ export const layerWith = (options?: LayerOptions) =>
479505 . transaction (
480506 ( ) =>
481507 Effect . gen ( function * ( ) {
482- const results = new Array < { aggregateID : string ; seq : number } > ( )
508+ // Aligned with entries by index: a deduped entry yields
509+ // undefined so the payload pairing below stays positional.
510+ const results = new Array < { aggregateID : string ; seq : number } | undefined > ( )
483511 for ( const entry of entries ) {
484512 // No replay input: seq is allocated contiguously from the latest sequence inside the transaction.
485513 const result = yield * commitDurableEventInner (
@@ -488,7 +516,7 @@ export const layerWith = (options?: LayerOptions) =>
488516 undefined ,
489517 entry . commit ,
490518 )
491- if ( result ) results . push ( result )
519+ results . push ( result )
492520 }
493521 return results
494522 } ) ,
@@ -499,19 +527,19 @@ export const layerWith = (options?: LayerOptions) =>
499527 return results
500528 } ) ,
501529 )
502- const payloads = entries . flatMap ( ( entry , index ) => {
530+ const payloads = entries . map ( ( entry , index ) => {
503531 const result = committed [ index ]
504- if ( ! result ) return [ ]
505- return [
506- {
507- ... entry . event ,
508- durable : {
509- aggregateID : result . aggregateID ,
510- seq : result . seq ,
511- version : entry . durable . version ,
512- } ,
513- } as Payload ,
514- ]
532+ // A deduped entry is still notified (mirrors the single-publish
533+ // path) but stays unstamped: it occupies no sequence position.
534+ if ( ! result ) return entry . event
535+ return {
536+ ... entry . event ,
537+ durable : {
538+ aggregateID : result . aggregateID ,
539+ seq : result . seq ,
540+ version : entry . durable . version ,
541+ } ,
542+ } as Payload
515543 } )
516544 for ( const payload of payloads ) {
517545 yield * notify ( payload )
0 commit comments