package records

import (
	"context"
	"database/sql"
	"fmt"
	"sync"

	"github.com/nexuscrm/audit"
	"github.com/nexuscrm/automation/autonumber"
	"github.com/nexuscrm/metadata"
	"github.com/nexuscrm/permissions"

	eventFeature "github.com/nexuscrm/events"
	appErrors "github.com/nexuscrm/kernel/pkg/errors"
	"github.com/nexuscrm/kernel/pkg/logging"
	"github.com/nexuscrm/kernel/pkg/ports"
	"github.com/nexuscrm/kernel/pkg/recordlocks"
	"github.com/nexuscrm/kernel/pkg/validation"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
	"golang.org/x/sync/singleflight"
)

// PersistenceService manages strict CRUD operations and data integrity.
type PersistenceService struct {
	repo             ports.RecordRepository
	metadata         *metadata.MetadataService
	permissions      *permissions.PermissionService
	eventBus         ports.EventPublisher
	validator        *metadata.ValidationService
	schemaValidator  *validation.SchemaValidator
	txManager        ports.TransactionManager
	rollup           ports.RollupProcessor
	outbox           *eventFeature.OutboxService
	logger           *logging.Logger
	autoNumber       autonumber.Engine
	autoNumberMeta   autoNumberMetadataProvider
	sessionRepo      ports.SessionRepository
	deactivationHook ports.UserDeactivationHook
	connectedAppHook ports.ConnectedAppInvalidationHook
	// refreshTokenRevoker is OPTIONAL. When set, a Connected App security-policy
	// change (or deletion) that revokes the app's sessions also revokes its OAuth
	// refresh tokens in the same transaction, so a refresh token cannot outlive the
	// change. Wired via SetConnectedAppRefreshTokenRevoker after construction so
	// the constructor signature stays stable.
	refreshTokenRevoker connectedAppRefreshTokenRevoker
	userPasswordRepo    currentUserPasswordLookup
	lifecycleHooks      *recordLifecycleHooks
	cacheInvalidations  *recordCacheInvalidations
	writeCanonicalizers *recordWriteCanonicalizers
	writeValidators     *recordWriteValidators
	// auditV2Emitter is injected only after composition proves the durable
	// audit-outbox schema and one active worker ownership epoch. A nil emitter
	// is an explicit disabled state, never a process-global readiness fallback.
	auditV2Emitter *audit.DBEmitter
	// rejectedSaveAuditSink records saves the validation pipeline REFUSED
	// (issue #4261). It is deliberately separate from auditV2Emitter: that
	// one writes inside the caller's business transaction, and a rejected
	// save's transaction is rolling back. A nil sink is an explicit disabled
	// state. See audit_rejected_save.go.
	rejectedSaveAuditSink audit.FailureSink
	// rejectedSaveAuditDisabled is the operator kill switch for the above.
	// Default (zero value) keeps recording ON.
	rejectedSaveAuditDisabled bool
	// fieldHistorySequenceReservationDisabled is an emergency availability
	// circuit breaker for the per-FieldId sequence rows. Its zero value keeps
	// reservation enabled so direct test/service literals preserve the normal
	// fail-closed history contract. NewPersistenceService reads the one process
	// setting that may explicitly disable it.
	fieldHistorySequenceReservationDisabled bool

	// querySvc backs the recycle-bin LIST fan-out
	// (persistence_recyclebin_fanout.go). Wired via SetQueryService after
	// construction; see that file for the reasoning. Other PersistenceService
	// code paths do not depend on it.
	querySvc ports.QueryService

	// selectedRootProjection is the narrow query/RLS seam for the bounded
	// selected-root point read. It is wired from the existing QueryService at
	// composition time; no new capability is added to contract.Deps.
	selectedRootProjection SelectedRootProjectionReader

	// activeFormulaProjector is OPTIONAL. When non-nil, the Insert path
	// projects active-formula fields into the outbox InternalMetadata so
	// the flow-trigger listener can overlay them onto $Record without a
	// second DB round-trip. Wired via SetActiveFormulaProjector after
	// construction so the constructor signature stays stable.
	//
	// This vertical slice wires it only for the INSERT path
	// (persistence_insert.go) — Update / BulkInsert remain in follow-up
	// slices. The field's presence on the struct is the only PersistenceService
	// change required for the INSERT slice; method bodies for Update /
	// BulkInsert are unchanged.
	activeFormulaProjector *activeFormulaProjector
	// rawDB powers the insert-inheritance applier's registry lookup
	// (bootstrap.GetFieldMappingsByTarget on _System_ObjectFieldMapping).
	// Nil-safe: when unset, the applier is a no-op — greenfield behavior
	// and test harnesses that don't need inheritance.
	rawDB *sql.DB
	// insertInheritanceMeta is the request-bound Runtime projection. Independent
	// Runtime composition installs it and never reads _System_ObjectFieldMapping
	// behind the Catalog revision bound by ServingAdmission. Legacy monolith
	// composition continues to use rawDB until its serving ownership flip.
	insertInheritanceMeta insertInheritanceMetadataProvider

	// parentLaneAdmission is the production/fake seam for durable parent-lane
	// admission.
	parentLaneAdmission ParentLaneAdmissionController

	// rollupDeltaWorker is present only in the independently deployed Runtime
	// Worker graph. Foreground mutation graphs author revision-bound rows but do
	// not acquire worker ownership or drain them.
	rollupDeltaWorker *runtimeRollupDeltaWorkerConfig

	parentLockScopesMu sync.Mutex
	parentLockScopes   map[interface{}]*recordlocks.Scope

	// createFromSourceGuardLoader is the test seam for the create-from-source
	// bypass guard (issue #1234). Nil → default rawDB-backed loader that
	// delegates to bootstrap.GetCreateFromSourceGuardEntries; non-nil → test-
	// injected fake. See SetGuardEntryLoader for the production / test contract.
	createFromSourceGuardLoader createFromSourceGuardLoader
	// createFromSourceGuardCandidateFieldLoader is the narrower production
	// precheck seam. It reads only candidate SourceLookupField names, without
	// validating every configured mapping up front.
	createFromSourceGuardCandidateFieldLoader createFromSourceGuardCandidateFieldLoader
	// createFromSourceGuardMeta is the immutable, request-bound Runtime
	// projection. When installed it is the sole guard read path: rawDB remains
	// available for record-data validation and locks but is never consulted for
	// this Catalog-owned fact.
	createFromSourceGuardMeta createFromSourceGuardMetadataProvider

	// createFromSourceGuardCache memoizes the derived guard registry by
	// lowercased target object so single-record Insert does not re-scan and
	// unmarshal active standard-action Flows on every request. Metadata writes
	// to the owning registry tables clear it through the same post-write
	// invalidation path as the security caches.
	createFromSourceGuardCacheMu             sync.RWMutex
	createFromSourceGuardCache               map[string]createFromSourceGuardCacheEntry
	createFromSourceGuardCandidateFieldCache map[string]createFromSourceGuardCandidateFieldCacheEntry
	createFromSourceGuardEpoch               uint64
	createFromSourceGuardLoadGroup           singleflight.Group
	insertInheritanceRuleLoader              insertInheritanceRuleLoader
	insertInheritanceCacheMu                 sync.RWMutex
	insertInheritanceCache                   map[string]insertInheritanceCacheEntry
	insertInheritanceEpoch                   uint64
	insertInheritanceLoadGroup               singleflight.Group
	oauthScopePolicyInvalidator              func()
	oauthPublicScopePolicy                   ConnectedAppPublicScopePolicyProvider
	oauthPublicScopePolicyResolver           ConnectedAppPublicScopePolicyResolver
	permissionStateAuthority                 PermissionStateAuthority
	assignmentPolicyAuthority                permissions.AssignmentPolicyDirectWriteAuthority
}

func (ps *PersistenceService) activeFormulaProjectionRequired(
	ctx context.Context,
	objectAPIName, eventType, recordTriggerType string,
) bool {
	if ps == nil || ps.metadata == nil {
		return true
	}
	return ps.metadata.HasActiveRecordTriggeredFlowForEvent(ctx, objectAPIName, eventType, recordTriggerType)
}

// resolveCompoundFields selects the declaration authority at the composition
// boundary. Independent Runtime requests use only their immutable,
// revision-bound schema. The legacy monolith keeps its existing process-local
// classifier until that deployment is retired.
func (ps *PersistenceService) resolveCompoundFields(schema *models.ObjectMetadata, data models.SObject) {
	if ps != nil && ps.metadata != nil && ps.metadata.UsesRequestBoundRuntimeView() {
		resolveRequestBoundCompoundFields(schema, data)
		return
	}
	resolveCompoundFields(schema, data)
}

// SetAuditV2Emitter activates atomic audit head/outbox production for this
// PersistenceService graph. Worker liveness is deliberately not process-local:
// the durable queue survives independent Runtime Worker restart.
func (ps *PersistenceService) SetAuditV2Emitter(emitter *audit.DBEmitter) {
	ps.auditV2Emitter = emitter
}

// SetAssignmentPolicyDirectWriteAuthority installs the L0 security authority
// consulted by metadata-declared PermissionSetAssignment write validation.
// Production composition must install it even before any policy claim exists:
// an absent claim is the dark no-op state, while an absent authority is a
// fail-closed wiring error once assignment-policy tables are available.
func (ps *PersistenceService) SetAssignmentPolicyDirectWriteAuthority(
	authority permissions.AssignmentPolicyDirectWriteAuthority,
) error {
	if ps == nil || authority == nil {
		return fmt.Errorf("records: assignment policy direct-write authority is required")
	}
	ps.assignmentPolicyAuthority = authority
	return nil
}

// SetRejectedSaveAuditSink activates FAILURE-row production for saves the
// validation pipeline refuses (issue #4261).
//
// The sink MUST NOT be enlisted in the caller's business transaction — it
// writes out-of-band precisely so the row survives the rollback that
// accompanies a rejection. Pass audit.NewDBFailureEmitter(db) in production.
func (ps *PersistenceService) SetRejectedSaveAuditSink(sink audit.FailureSink) {
	ps.rejectedSaveAuditSink = sink
}

// NewPersistenceService creates a new PersistenceService with all dependencies.
//
// Autonumber v2 cutover (PRD §3.1 / §7.2): the engine + its metadata source
// are injected here so Insert / BulkInsert can render `auto_number_format`
// values before publishing RecordBeforeCreate events. Passing a nil engine
// disables autonumber assignment (useful for some tests); production wiring
// always provides a real engine.
func NewPersistenceService(
	repo ports.RecordRepository,
	rollup ports.RollupProcessor,
	metadata *metadata.MetadataService,
	permissions *permissions.PermissionService,
	eventBus ports.EventPublisher,
	validator *metadata.ValidationService,
	txManager ports.TransactionManager,
	outbox *eventFeature.OutboxService,
	logger *logging.Logger,
	autoNumber autonumber.Engine,
	autoNumberMeta *autonumber.MetadataSource,
	sessionRepo ports.SessionRepository,
	deactivationHook ports.UserDeactivationHook,
) *PersistenceService {
	ps := &PersistenceService{
		repo:                repo,
		rollup:              rollup,
		metadata:            metadata,
		permissions:         permissions,
		eventBus:            eventBus,
		validator:           validator,
		schemaValidator:     validation.NewSchemaValidator(),
		txManager:           txManager,
		outbox:              outbox,
		logger:              logger,
		autoNumber:          autoNumber,
		sessionRepo:         sessionRepo,
		deactivationHook:    deactivationHook,
		lifecycleHooks:      newRecordLifecycleHooks(),
		cacheInvalidations:  newRecordCacheInvalidations(),
		writeCanonicalizers: newRecordWriteCanonicalizers(),
		writeValidators:     newRecordWriteValidators(),
	}
	ps.fieldHistorySequenceReservationDisabled = fieldHistorySequenceReservationDisabledFromEnv()
	ps.rejectedSaveAuditDisabled = rejectedSaveAuditDisabledFromEnv()
	if hook, ok := deactivationHook.(ports.ConnectedAppInvalidationHook); ok {
		ps.connectedAppHook = hook
	}
	if autoNumberMeta != nil {
		ps.autoNumberMeta = autoNumberMeta
	}
	ps.registerBuiltInRecordLifecycleHooks()
	ps.registerBuiltInSecurityCacheInvalidations()
	ps.registerBuiltInSecurityWriteCanonicalizers()
	ps.registerBuiltInSecurityWriteValidators()
	ps.registerBuiltInFolderWriteCanonicalizers()
	ps.registerBuiltInFolderWriteValidators()
	return ps
}

// SetAutoNumberRuntime installs the request-capable autonumber engine and its
// metadata projection as one unit. Independent Runtime uses this after
// constructing a repository-free MetadataService; legacy composition keeps
// using the constructor parameters above.
func (ps *PersistenceService) SetAutoNumberRuntime(engine autonumber.Engine, provider autoNumberMetadataProvider) error {
	if ps == nil || engine == nil || provider == nil {
		return fmt.Errorf("records: autonumber engine and metadata provider are required")
	}
	ps.autoNumber = engine
	ps.autoNumberMeta = provider
	return nil
}

// SetInsertInheritanceRuntime installs the immutable request-bound mapping
// projection used by independent Runtime mutation. It is deliberately
// separate from SetRawDB: the two are alternative read paths for different
// compositions, and Runtime must never fall back to live registry reads.
func (ps *PersistenceService) SetInsertInheritanceRuntime(provider insertInheritanceMetadataProvider) error {
	if ps == nil || provider == nil {
		return fmt.Errorf("records: insert-inheritance Runtime metadata provider is required")
	}
	ps.insertInheritanceMeta = provider
	ps.clearInsertInheritanceCache()
	return nil
}

// SetCreateFromSourceGuardRuntime installs the revision-bound guard projection
// used by independent Runtime. The provider is intentionally separate from
// SetRawDB so Runtime can use the database for L3 reads without re-opening a
// live Catalog metadata path.
func (ps *PersistenceService) SetCreateFromSourceGuardRuntime(provider createFromSourceGuardMetadataProvider) error {
	if ps == nil || provider == nil {
		return fmt.Errorf("records: create-from-source Runtime metadata provider is required")
	}
	ps.createFromSourceGuardMeta = provider
	ps.clearCreateFromSourceGuardCache()
	return nil
}

// SetOAuthScopePolicyInvalidator wires the process-local cache invalidator for
// OAuth scope policy and Connected App scope-owner bindings. The records layer
// owns the post-commit mutation signal, while the HTTP middleware owns the
// derived OAuth policy cache.
func (ps *PersistenceService) SetOAuthScopePolicyInvalidator(invalidator func()) {
	ps.oauthScopePolicyInvalidator = invalidator
}

// InvalidateOAuthScopePolicy schedules a reload of the derived OAuth policy
// cache after the current transaction commits. Direct system-registry writers
// use this for L2 scope-owner bindings, which are not ordinary SObject writes.
func (ps *PersistenceService) InvalidateOAuthScopePolicy(ctx context.Context) {
	ps.invalidateOAuthScopePolicy(ctx)
}

// SetOAuthPublicScopePolicyProvider wires the same public OAuth scope policy
// source used by token issuance into Connected App mutation validation.
func (ps *PersistenceService) SetOAuthPublicScopePolicyProvider(provider ConnectedAppPublicScopePolicyProvider) {
	ps.oauthPublicScopePolicy = provider
	ps.oauthPublicScopePolicyResolver = nil
}

// SetOAuthPublicScopePolicyResolver installs the request-bound policy owner.
// Installing either form clears the other so one process cannot accidentally
// consult two authorities for the same Connected App decision.
func (ps *PersistenceService) SetOAuthPublicScopePolicyResolver(resolver ConnectedAppPublicScopePolicyResolver) {
	ps.oauthPublicScopePolicyResolver = resolver
	ps.oauthPublicScopePolicy = nil
}

// SetConnectedAppInvalidationHook wires cache invalidation for OAuth Connected
// App active-state checks. Production receives this from AuthService through
// the constructor; tests can use the setter without building the auth stack.
func (ps *PersistenceService) SetConnectedAppInvalidationHook(hook ports.ConnectedAppInvalidationHook) {
	ps.connectedAppHook = hook
}

// RecomputeActiveFormulaProjection re-fetches active-formula values for the
// given (object, recordID) through the formula view and returns them as a
// new SObject snapshot. Used by FlowExecutor's chained-reread reproject path
// (executor_record_ops.go) after an in-flow RecordUpdate of the trigger
// record, so subsequent $Record.<active> reads see post-update active values
// (issue #1377, slice 4).
//
// Dispatches between strict and best-effort projector variants by the
// caller's security context:
//
//   - CRUD/FLS-bypassed ctx (ElevatedContext+, includes the SystemContext
//     used by record-triggered flows): the view returns every active field,
//     so a missing field/row signals view drift or rebuild lag, not an
//     authorized omission. Use strict project() — fail closed so the caller
//     surfaces / retries instead of branching on stale active values.
//   - User-level ctx (UserContext / ServiceContext, used by user-invoked
//     flows): the view honors FLS, so FLS-hidden active fields will be
//     absent from the view row and that must not raise. Use
//     projectBestEffort() — overlay whatever the user can read and leave
//     hidden fields untouched.
//
// fields restricts the projection to the given active-field API names
// (matched case-insensitively against the schema's active fields); nil means
// every active field. Field-scoped callers — approval criteria evaluation,
// whose read authority is capped at the fields the criteria author wrote
// (#3945) — pass the referenced subset so the strict variant selects AND
// validates only those fields: an unrelated active field missing from the
// view row (drift on a field the caller never asked about) must not fail a
// scoped read.
//
// Returns (nil, nil) when:
//   - the projector is not wired (test harness / setup gap — degraded mode)
//   - objectAPIName or recordID is empty
//   - the schema has no active fields (none matching `fields`, when given)
//   - (best-effort only) row not visible under caller's RLS
//
// Returns (nil, error) on transient query failure, and additionally on
// missing key / absent row when running under CRUD/FLS-bypassed ctx.
func (ps *PersistenceService) RecomputeActiveFormulaProjection(
	ctx context.Context,
	user *models.UserSession,
	objectAPIName, recordID string,
	fields []string,
) (models.SObject, error) {
	if ps == nil || ps.activeFormulaProjector == nil || objectAPIName == "" || recordID == "" {
		return nil, nil
	}
	schema := ps.metadata.GetSchema(ctx, objectAPIName)
	if schema == nil {
		return nil, nil
	}
	if constants.GetSecurityContext(ctx).BypassesCRUDandFLS() {
		return ps.activeFormulaProjector.projectFields(ctx, user, schema, recordID, fields)
	}
	return ps.activeFormulaProjector.projectBestEffort(ctx, user, schema, recordID, fields)
}

// SetActiveFormulaProjector wires the active-formula projection adapter so
// the Insert path stamps a formula-view snapshot into the outbox
// InternalMetadata. The snapshot is consumed by FlowTriggerListener to
// overlay $Record with up-to-date active field values.
//
// The port argument wraps analytics.NewActiveFormulaProjectionAdapter.
// Kept as a separate setter (not a constructor parameter) so the wide
// NewPersistenceService signature does not grow and test harnesses that do
// not exercise active formulas remain zero-change.
//
// When the port is nil (test harness, or production with no projector
// wired), Insert skips projection and listeners fall back to raw
// payload.Record — correct degraded behavior for schemas with no active
// formula fields.
func (ps *PersistenceService) SetActiveFormulaProjector(port ActiveFormulaProjectionPort) {
	if port == nil {
		ps.activeFormulaProjector = nil
		return
	}
	ps.activeFormulaProjector = newActiveFormulaProjector(port)
}

// SetRawDB wires the raw *sql.DB used by the insert-inheritance applier
// (features/records/insert_inheritance.go) to read _System_ObjectFieldMapping
// rules. Separate setter so the wide NewPersistenceService constructor does
// not grow another parameter. Unset in test harnesses that don't exercise
// inheritance — the applier no-ops cleanly in that case.
//
// Production wiring lives at
// application/services/service_manager_init.go right after
// NewPersistenceService — and is asserted by the regression test
// services.TestServiceManager_PersistenceHasRawDB so a future refactor
// that drops the setter call fails loudly.
func (ps *PersistenceService) SetRawDB(db *sql.DB) {
	ps.rawDB = db
	ps.clearCreateFromSourceGuardCache()
	ps.clearInsertInheritanceCache()
}

// HasRawDB is a read-only accessor for tests / health checks to verify
// the insert-inheritance applier's registry dependency is wired in
// production. Not consumed by hot-path code.
func (ps *PersistenceService) HasRawDB() bool {
	return ps.rawDB != nil
}

// RawDB exposes the injected raw *sql.DB for internal scheduler / substrate
// adapters that need a narrow escape hatch to an existing generic store.
// Nil-safe for tests that do not wire the handle.
func (ps *PersistenceService) RawDB() *sql.DB {
	return ps.rawDB
}

// Insert operations are in persistence_insert.go:
// - Insert

// Update operations are in persistence_update.go:
// - Update, updateWithPostLockDerivedValues

// PrelockUpdateParents lets outer pessimistic transaction owners acquire the
// authoritative update parent-lock set before unrelated writes in the same tx
// introduce later-sorting keys (for example approval workitem ActorId -> User).
func (ps *PersistenceService) PrelockUpdateParents(
	ctx context.Context,
	objectName string,
	id string,
	updates models.SObject,
	currentUser *models.UserSession,
) error {
	return ps.runParentLockCoordinatorIfActive(ctx, func(prelockCtx context.Context) error {
		schema, err := ps.prepareOperation(prelockCtx, objectName, constants.PermEdit, currentUser)
		if err != nil {
			return err
		}
		updates, _, err = ps.prepareUpdateRequest(prelockCtx, objectName, id, schema, updates, currentUser)
		if err != nil {
			return err
		}
		_, _, err = ps.prelockInitialUpdateParents(
			prelockCtx,
			objectName,
			schema,
			id,
			updates,
			currentUser,
			parentLockForcedPrelocksFromContext(prelockCtx),
		)
		return err
	})
}

// PrelockPreparedUpdateParents lets outer pessimistic transaction owners
// acquire parent locks from a previewed post-before-save record image.
func (ps *PersistenceService) PrelockPreparedUpdateParents(
	ctx context.Context,
	objectName string,
	id string,
	prepared *models.PreparedRecordUpdate,
	extraUpdates models.SObject,
	currentUser *models.UserSession,
) error {
	if prepared == nil {
		return appErrors.NewInternalError("prepared update missing", nil)
	}
	return ps.runParentLockCoordinatorIfActive(ctx, func(prelockCtx context.Context) error {
		schema, err := ps.prepareOperation(prelockCtx, objectName, constants.PermEdit, currentUser)
		if err != nil {
			return err
		}
		_, _, err = ps.prelockInitialPreparedUpdateParents(
			prelockCtx,
			objectName,
			id,
			schema,
			prepared.Clone(),
			cloneSObject(extraUpdates),
			currentUser,
			parentLockForcedPrelocksFromContext(prelockCtx),
		)
		return err
	})
}

// PlanPreparedUpdateParentKeys derives the complete parent-lock intent of a
// prepared update without acquiring any row locks. Transaction owners that
// compose several writes can union these keys with their own parents and lock
// one globally ordered set before the first mutation. The real commit still
// repeats the authoritative derivation under lock; this plan is only an early
// lock intent and must never be used as a prepared-write freshness proof.
func (ps *PersistenceService) PlanPreparedUpdateParentKeys(
	ctx context.Context,
	objectName string,
	id string,
	prepared *models.PreparedRecordUpdate,
	extraUpdates models.SObject,
	currentUser *models.UserSession,
) ([]recordlocks.ParentKey, error) {
	if prepared == nil {
		return nil, appErrors.NewInternalError("prepared update missing", nil)
	}
	schema, err := ps.prepareOperation(ctx, objectName, constants.PermEdit, currentUser)
	if err != nil {
		return nil, err
	}
	keys, _, _, err := ps.collectInitialPreparedUpdateParentKeys(
		ctx,
		objectName,
		id,
		schema,
		prepared.Clone(),
		cloneSObject(extraUpdates),
		currentUser,
		nil,
	)
	if err != nil {
		return nil, err
	}
	return recordlocks.OrderedParentKeys(keys), nil
}

// Update helper implementations live in persistence_update_helpers.go.

func (ps *PersistenceService) completeUpdateAfterBeforeSave(txCtx context.Context, ctx context.Context, objectName, id string, schema *models.ObjectMetadata, recordToValidate, effectiveUpdates, oldRecord models.SObject, currentUser *models.UserSession, permChanges, currentGrants map[string]bool, parentPrelocks map[string]struct{}) (models.SObject, error) {
	return ps.completeUpdateAfterBeforeSaveWithPostWrite(txCtx, ctx, objectName, id, schema, recordToValidate, effectiveUpdates, oldRecord, currentUser, permChanges, currentGrants, parentPrelocks, func(txCtx context.Context) error {
		return ps.handlePostUpdateSessionInvalidations(txCtx, objectName, id, oldRecord, effectiveUpdates)
	})
}
