Portal Community

Table Schema

-- Process_NodePinnedData
CREATE TABLE Process_NodePinnedData (
    Id          BIGINT          IDENTITY PRIMARY KEY,
    TenantId    NVARCHAR(64)    NOT NULL,
    ProcessId   INT             NOT NULL,
    NodeId      NVARCHAR(256)   NOT NULL,
    DataJson    NVARCHAR(MAX)   NOT NULL,   -- JSON-serialized pinnedData object
    CreatedAt   DATETIMEOFFSET  NOT NULL DEFAULT SYSUTCDATETIME(),
    UpdatedAt   DATETIMEOFFSET  NOT NULL DEFAULT SYSUTCDATETIME(),
    ExpiresAt   DATETIMEOFFSET  NULL,       -- NULL = never expires
    CONSTRAINT UQ_NodePinnedData UNIQUE (TenantId, ProcessId, NodeId)
);

PinnedDataService — Save and Upsert

// PinnedDataService.cs
public async Task SaveAsync(
    int processId,
    string nodeId,
    object data,
    CancellationToken ct = default)
{
    var json     = JsonSerializer.Serialize(data);
    var tenantId = _tenantContext.TenantId;

    // Upsert — INSERT if first time, UPDATE if already exists
    await _db.ExecuteAsync(@"
        MERGE Process_NodePinnedData AS target
        USING (SELECT @TenantId, @ProcessId, @NodeId) AS source (TenantId, ProcessId, NodeId)
        ON target.TenantId = source.TenantId
            AND target.ProcessId = source.ProcessId
            AND target.NodeId    = source.NodeId
        WHEN MATCHED THEN
            UPDATE SET DataJson = @Json, UpdatedAt = SYSUTCDATETIME()
        WHEN NOT MATCHED THEN
            INSERT (TenantId, ProcessId, NodeId, DataJson, CreatedAt, UpdatedAt)
            VALUES (@TenantId, @ProcessId, @NodeId, @Json, SYSUTCDATETIME(), SYSUTCDATETIME());",
        new { TenantId = tenantId, ProcessId = processId, NodeId = nodeId, Json = json },
        cancellationToken: ct);
}

Key Semantics

BehaviourDetail
ScopePer (TenantId, ProcessId, NodeId) — shared across ALL executions of the same workflow node
OverwriteEach write replaces the entire DataJson column — it is not a merge or append. Read-modify-write is the caller's responsibility.
First writeINSERTs a new row with CreatedAt = UpdatedAt = now
Subsequent writesUPDATEs only DataJson and UpdatedAt
Null pinnedDataNo database call at all — existing row is not touched
ExpiryExpiresAt is NULL by default. Set a TTL via the optional overload of SaveAsync.

Read Path

// PinnedDataService.cs
public async Task<object?> GetAsync(
    int processId,
    string nodeId,
    CancellationToken ct = default)
{
    var tenantId = _tenantContext.TenantId;

    var json = await _db.QuerySingleOrDefaultAsync<string>(@"
        SELECT DataJson
        FROM   Process_NodePinnedData
        WHERE  TenantId  = @TenantId
          AND  ProcessId = @ProcessId
          AND  NodeId    = @NodeId
          AND  (ExpiresAt IS NULL OR ExpiresAt > SYSUTCDATETIME())",
        new { TenantId = tenantId, ProcessId = processId, NodeId = nodeId },
        cancellationToken: ct);

    return json is null ? null : JsonSerializer.Deserialize<object>(json);
}
One row per node — overwrite is intentional. Pinned data is designed to hold the LATEST state of a node, not a history of states. If you need historical records across runs, use a separate persistence mechanism (e.g., write to a database via a DataNode, or log the data for Loki ingestion).
ExpiresAt filtering happens in the query. Expired rows are not proactively deleted — they are silently excluded from reads. A background cleanup job (configurable interval, default 24h) removes expired rows to keep the table lean.