Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7add260
feat(swift-sdk): add core wallet balance diagnostics
llbartekll Sep 1, 2026
2ed8c1d
fix(swift-sdk): keep deep core diagnostics off startup
llbartekll Sep 4, 2026
345134c
fix(swift-sdk): address diagnostics review
llbartekll Sep 4, 2026
33a7f2b
fix(swift-sdk): address core wallet diagnostics review
llbartekll Sep 7, 2026
e27c024
fix(swift-sdk): scope the migration fallback to the store's version, …
llbartekll Sep 7, 2026
9d75b93
fix(swift-sdk): lower the exact-audit transaction ceiling to 10k
llbartekll Sep 7, 2026
8fc2003
fix(swift-sdk): refuse the migration fallback for newer stores, and r…
llbartekll Sep 7, 2026
81fc3bf
fix(swift-sdk): bound the migration fallback to the entities known to…
llbartekll Sep 7, 2026
fa4b6a7
fix(swift-sdk): pin the drifted entities by hash, not by name
llbartekll Sep 7, 2026
7fd84c0
feat(swift-sdk): surface a refused newer-build store as a typed error
llbartekll Sep 7, 2026
668a707
fix(swift-sdk): drain both halves of the export, snapshot committed s…
llbartekll Sep 7, 2026
d86a763
fix(swift-sdk): never claim drift from a comparison that did not happen
llbartekll Sep 8, 2026
abde130
fix(swift-sdk): only claim "newer build" where the evidence has a dir…
llbartekll Sep 8, 2026
4d0533e
Merge branch 'v4.2-dev' into codex/cj-balance-diagnostics-sdk
llbartekll Sep 8, 2026
c0469ac
fix(swift-sdk): drain only what this manager can finish
llbartekll Sep 8, 2026
2eb5437
Merge remote-tracking branch 'origin/codex/cj-balance-diagnostics-sdk…
llbartekll Sep 8, 2026
d0cddbc
test(swift-sdk): stop pinning the drift reason to the whole entity list
llbartekll Sep 8, 2026
3e64599
Merge remote-tracking branch 'origin/v4.2-dev' into codex/cj-balance-…
llbartekll Sep 8, 2026
5cbb732
fix(swift-sdk): drop the "newer build" claim entirely — nothing here …
llbartekll Sep 8, 2026
c686477
fix(swift-sdk): judge each address pool only where accounts should ha…
llbartekll Sep 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/swift-sdk/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ let package = Package(
.testTarget(
name: "SwiftDashSDKTests",
dependencies: ["SwiftDashSDK"],
path: "SwiftTests/SwiftDashSDKTests"
path: "SwiftTests/SwiftDashSDKTests",
resources: [.copy("Fixtures")]
),

// Integration tests against a local dashmate devnet.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,21 +213,51 @@ enum SDKLogFormatter {
}
}

private final class SDKLoggerState: @unchecked Sendable {
/// Internal rather than private so the pre-install buffer can be tested on a
/// fresh instance: the process-wide `SDKLogger.state` has no way back to the
/// "no sink installed" condition once any test has installed one.
final class SDKLoggerState: @unchecked Sendable {
/// How many pre-install events are retained for replay. A host that never
/// installs a sink must not accumulate lines for the life of the process,
/// so the buffer drops its oldest entries and reports the loss instead.
static let pendingLineLimit = 256

private let lock = NSLock()
private var sink: SDKLogFileSink?
private var includeDebug = false

func installSink(at sessionDirectory: URL, includeDebug: Bool) -> Bool {
/// Events emitted before the file sink exists. `DashModelContainer.create`
/// runs in the host's `init()`, long before `LoggingPreferences.configure()`
/// installs the sink, so without this buffer the store-open result — and
/// every other launch-path event — would only ever reach the console and
/// never the exported `swift/run.log`.
private var pendingLines: [(severity: SDKLogSeverity, line: String)] = []
private var droppedPendingLineCount = 0

/// Installs the sink and replays what was emitted before it existed, in
/// emission order and under the sink's own debug filter.
func installSink(at sessionDirectory: URL, includeDebug: Bool) -> (
installed: Bool,
droppedPendingLineCount: Int
) {
do {
let newSink = try SDKLogFileSink(sessionDirectory: sessionDirectory)
lock.withLock {
// Replay under the same lock `record` takes, so a line emitted
// concurrently with the install cannot land in front of the
// backlog it actually followed.
let dropped: Int = lock.withLock {
sink = newSink
self.includeDebug = includeDebug
for entry in pendingLines where entry.severity != .debug || includeDebug {
newSink.write(entry.line)
}
let droppedCount = droppedPendingLineCount
pendingLines = []
droppedPendingLineCount = 0
return droppedCount
}
return true
return (installed: true, droppedPendingLineCount: dropped)
} catch {
return false
return (installed: false, droppedPendingLineCount: 0)
}
}

Expand All @@ -237,11 +267,22 @@ private final class SDKLoggerState: @unchecked Sendable {
}
}

func destination(for severity: SDKLogSeverity) -> SDKLogFileSink? {
lock.withLock {
/// Routes one formatted line to the sink, or buffers it for replay when no
/// sink has been installed yet.
func record(severity: SDKLogSeverity, line: String) {
Comment thread
llbartekll marked this conversation as resolved.
let destination: SDKLogFileSink? = lock.withLock {
guard let sink else {
if pendingLines.count >= Self.pendingLineLimit {
pendingLines.removeFirst()
Comment thread
llbartekll marked this conversation as resolved.
Outdated
droppedPendingLineCount += 1
}
pendingLines.append((severity: severity, line: line))
return nil
}
guard severity != .debug || includeDebug else { return nil }
return sink
}
destination?.write(line)
}

func flush() {
Expand Down Expand Up @@ -473,7 +514,7 @@ public enum SDKLogger {
redacting: sensitiveValues
)

state.destination(for: severity)?.write(line)
state.record(severity: severity, line: line)

let shouldMirrorToConsole: Bool
switch severity {
Expand All @@ -496,7 +537,25 @@ public enum SDKLogger {
}

static func installFileSink(at sessionDirectory: URL, includeDebug: Bool) -> Bool {
state.installSink(at: sessionDirectory, includeDebug: includeDebug)
let outcome = state.installSink(
at: sessionDirectory,
includeDebug: includeDebug
)
if outcome.droppedPendingLineCount > 0 {
// Emitted after the replay so the gap is visible at the point in
// the file where the missing lines would have been.
event(
"log_pre_install_buffer_overflow",
category: .lifecycle,
severity: .warning,
fields: [
"dropped_line_count": .integer(
Int64(outcome.droppedPendingLineCount)
),
]
)
}
return outcome.installed
}

static func updateDebugSetting(_ includeDebug: Bool) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,125 @@
import CoreData
import Foundation
import SwiftData

/// Factory for creating SwiftData model containers for Dash Platform persistence
public enum DashModelContainer {
private struct StoreFileSizes {
let main: UInt64
let wal: UInt64
let shm: UInt64

/// Shares the exporter's saturating rule so a corrupt size can never
/// trap and so both totals move together if that rule ever changes.
var total: UInt64 {
Comment thread
llbartekll marked this conversation as resolved.
diagnosticSaturatingSum([main, wal, shm])
}
}

/// Which of the two open attempts produced the result being reported.
private enum StoreMigrationPath: String {
/// `DashMigrationPlan` accepted the store.
case staged
/// The staged plan rejected the store and SwiftData's inferred
/// lightweight migration was used instead — see `create`.
case inferredFallback = "inferred_fallback"
}

/// Whether the store at `storeURL` was written by a schema that
/// `DashMigrationPlan` registers.
///
/// This is the question staged migration asks and answers with Cocoa
/// 134504 ("Cannot use staged migration with an unknown model version")
/// when the answer is no. It has to be asked here directly, because the
/// error SwiftData surfaces for it is `SwiftDataError.loadIssueModelContainer`
/// with no explanation and no underlying `NSError` — the same value a
/// corrupt file produces — so nothing in the thrown error distinguishes the
/// one failure the fallback may answer from every failure it must not.
///
/// Returns `nil` when the metadata cannot be read at all: that is not a
/// version question, and the caller treats it exactly like a match.
static func storeMatchesRegisteredSchema(at storeURL: URL) -> Bool? {
guard let metadata = try? NSPersistentStoreCoordinator.metadataForPersistentStore(
ofType: NSSQLiteStoreType,
at: storeURL,
options: nil
) else { return nil }
return DashMigrationPlan.schemas.contains { schema in
guard let model = NSManagedObjectModel.makeManagedObjectModel(for: schema.models)
else { return false }
return model.isConfiguration(withName: nil, compatibleWithStoreMetadata: metadata)
}
}

/// Builds the common payload for both sides of the container open. The
/// outcome deliberately describes only what SwiftData tells us: opening an
/// existing store may have included a migration, but this API does not
/// expose whether one actually ran.
private static func storeOpenFields(
succeeded: Bool,
existedBefore: Bool,
migrationPath: StoreMigrationPath,
startedAt: CFAbsoluteTime,
sizeBefore: StoreFileSizes,
sizeAfter: StoreFileSizes
) -> [String: SDKLogValue] {
let elapsed = max(0, (CFAbsoluteTimeGetCurrent() - startedAt) * 1_000)
let duration: UInt64
if !elapsed.isFinite {
duration = 0
} else if elapsed >= Double(UInt64.max) {
duration = UInt64.max
} else {
duration = UInt64(elapsed)
}
let openOutcome: String
switch (succeeded, existedBefore) {
case (true, true):
openOutcome = "existing_store_opened"
case (true, false):
openOutcome = "new_store_created"
case (false, true):
openOutcome = "existing_store_open_or_migration_failed"
case (false, false):
openOutcome = "new_store_creation_failed"
}

return [
"container_result": .publicText(succeeded ? "opened" : "open_failed"),
"duration_ms": .unsignedInteger(duration),
"migration_path": .publicText(migrationPath.rawValue),
"result": .publicText(succeeded ? "success" : "failure"),
"store_existed_before_open": .boolean(existedBefore),
"store_main_size_bytes_after": .unsignedInteger(sizeAfter.main),
"store_main_size_bytes_before": .unsignedInteger(sizeBefore.main),
"store_open_outcome": .publicText(openOutcome),
"store_shm_size_bytes_after": .unsignedInteger(sizeAfter.shm),
"store_shm_size_bytes_before": .unsignedInteger(sizeBefore.shm),
"store_size_bytes_after": .unsignedInteger(sizeAfter.total),
"store_size_bytes_before": .unsignedInteger(sizeBefore.total),
"store_wal_size_bytes_after": .unsignedInteger(sizeAfter.wal),
"store_wal_size_bytes_before": .unsignedInteger(sizeBefore.wal),
]
}

/// SQLite's durable state can be mostly in the WAL immediately after an
/// app kill, so the main file alone is not a useful corruption signal.
/// Read only sizes and never include any component of the device path.
private static func storeFileSizes(at storeURL: URL) -> StoreFileSizes {
Comment thread
llbartekll marked this conversation as resolved.
func fileSize(at url: URL) -> UInt64 {
guard let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize,
size >= 0
else { return 0 }
return UInt64(size)
}

return StoreFileSizes(
main: fileSize(at: storeURL),
wal: fileSize(at: URL(fileURLWithPath: storeURL.path + "-wal")),
shm: fileSize(at: URL(fileURLWithPath: storeURL.path + "-shm"))
)
}

/// Every registered schema version's model list, parameterised on the
/// one model whose shape differs between versions.
///
Expand Down Expand Up @@ -88,21 +205,120 @@ public enum DashModelContainer {
cloudKit: Bool = false,
groupContainer: ModelConfiguration.GroupContainer = .automatic
) throws -> ModelContainer {
let modelConfiguration = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false,
allowsSave: true,
groupContainer: groupContainer,
cloudKitDatabase: cloudKit ? .automatic : .none
return try open(
ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false,
allowsSave: true,
groupContainer: groupContainer,
cloudKitDatabase: cloudKit ? .automatic : .none
)
)
}

/// The instrumented store-opening path, parameterised on the configuration.
///
/// Public so a host that builds its own `ModelConfiguration` — DashWallet
/// does, with a per-network URL — gets the same `core_store_open_result`
/// telemetry and the same narrowly-scoped migration fallback as `create`,
/// instead of a bare `ModelContainer(for:configurations:)` that reports
/// nothing. It is also what lets `Dev1StoreUpgradeTests` drive exactly the
/// path that ships against a fixture store.
public static func open(_ modelConfiguration: ModelConfiguration) throws -> ModelContainer {
Comment thread
llbartekll marked this conversation as resolved.
// Always wire the migration plan so stores created by an older SDK
// advance through the registered versioned schemas.
return try ModelContainer(
for: schema,
migrationPlan: DashMigrationPlan.self,
configurations: [modelConfiguration]
)
// advance through the registered versioned schemas. Record only
// metadata about the store — never its device path.
let storeURL = modelConfiguration.url
let existedBefore = FileManager.default.fileExists(atPath: storeURL.path)
let sizeBefore = storeFileSizes(at: storeURL)
let started = CFAbsoluteTimeGetCurrent()

func report(
succeeded: Bool,
migrationPath: StoreMigrationPath,
error: Error? = nil
) {
SDKLogger.event(
Comment thread
llbartekll marked this conversation as resolved.
"core_store_open_result",
category: .persistence,
severity: succeeded ? .info : .error,
fields: storeOpenFields(
succeeded: succeeded,
existedBefore: existedBefore,
migrationPath: migrationPath,
startedAt: started,
sizeBefore: sizeBefore,
sizeAfter: storeFileSizes(at: storeURL)
),
error: error,
redacting: [storeURL.path]
)
}

do {
let container = try ModelContainer(
for: schema,
migrationPlan: DashMigrationPlan.self,
configurations: [modelConfiguration]
)
report(succeeded: true, migrationPath: .staged)
return container
} catch {
// Staged migration matches a store by the CHECKSUM of each
// registered `VersionedSchema`, and only `PersistentAssetLock` is
// frozen so far (see `DashSchemaFrozenModels.swift`). Every other
// V1/V2 model is still referenced live, so a shape that has drifted
// since — `PersistentDocumentType` and `PersistentIndex` for the
// v4.2.0-dev.1 stores `Dev1StoreUpgradeTests` pins — leaves the
// real store matching no registered version, and the staged open
// fails with Cocoa 134504 rather than migrating.
//
// Hosts turn that throw into `fatalError` at launch, so for THAT
// failure retry the way they already open the store themselves:
// the current schema with SwiftData's inferred lightweight
// migration and no plan. The match is deliberately exact, and it
// is made on the store rather than the error (see
// `storeMatchesRegisteredSchema`). Every stage in
// `DashMigrationPlan` is `.lightweight` today, but the day a
// custom stage lands, a failure inside it must surface — a store
// that matches a registered version and still failed to open is
// exactly that case, and falling back would reopen it without the
// stage and stamp the current checksum on it, so the stage could
// never run later. Everything except "existing store, matches no
// registered version" is therefore rethrown untouched, with the
// failed staged attempt reported as such.
guard existedBefore,
Comment thread
llbartekll marked this conversation as resolved.
Outdated
Self.storeMatchesRegisteredSchema(at: storeURL) == false
else {
report(succeeded: false, migrationPath: .staged, error: error)
throw error
}
SDKLogger.event(
"core_store_staged_migration_failed",
category: .persistence,
severity: .warning,
fields: [
"store_existed_before_open": .boolean(existedBefore),
],
error: error,
redacting: [storeURL.path]
)
do {
let container = try ModelContainer(
for: schema,
configurations: [modelConfiguration]
)
report(succeeded: true, migrationPath: .inferredFallback)
return container
} catch let fallbackError {
report(
succeeded: false,
migrationPath: .inferredFallback,
error: fallbackError
)
throw fallbackError
}
}
}

/// Create an in-memory model container for testing
Expand Down
Loading
Loading