chore: freeze verified etaf-sqlite baseline before C1b

This commit is contained in:
Kinneyzhang 2026-09-05 05:07:38 +08:00
parent fc0ec68447
commit 911e08767a
4 changed files with 253 additions and 25 deletions

View File

@ -13,9 +13,14 @@
(database (etaf-sqlite-database "~/items.sqlite" table))
(source (etaf-sqlite-source database)))
(etaf-sqlite-initialize database)
(etaf-data-controller source :auto-load t))
(etaf-data-controller source :auto-load t))
```
The source keeps the legacy `:mutate` callback by default. The additive
commit-certified wrapper is opt-in: use `(etaf-sqlite-source database
:mutate-v2 t)` when a controller needs explicit commit/rollback certainty and
read-only reconciliation.
Other databases, REST services, files, and ORMs should implement the same source capability in their own packages; they do not belong in ETAF core or in a generic `etaf-adapters` layer.
Run `make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs` from this directory.

View File

@ -16,6 +16,10 @@
(etaf-data-controller source :auto-load t))
```
默认 source 仍只暴露兼容的 `:mutate` callback。需要明确的提交/回滚确定性
和只读 reconciliation 时,显式使用 `(etaf-sqlite-source database
:mutate-v2 t)` 开启增量 wrapper。
其他数据库、REST、文件和 ORM 应在各自的具体数据源包中实现同一个 source capability它们不进入 ETAF core也不创建笼统的 `etaf-adapters` 层。
在该目录运行 `make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs`

View File

@ -25,6 +25,19 @@
'etaf-sqlite-error)
(define-error 'etaf-sqlite-unavailable "SQLite is unavailable"
'etaf-sqlite-error)
(define-error 'etaf-sqlite-commit-unknown
"SQLite commit outcome is unknown"
'etaf-sqlite-error)
;; Opaque tokens let Data reconcile a committed external mutation without
;; coupling itself to SQLite connection or transaction objects.
(defvar etaf-sqlite--reconciliation-sequence 0)
(defvar etaf-sqlite--mutation-active nil)
(defvar etaf-sqlite--mutation-phase nil)
(defun etaf-sqlite--reconciliation-token ()
"Return a fresh opaque token for one SQLite mutation outcome."
(list :etaf-sqlite (cl-incf etaf-sqlite--reconciliation-sequence)))
(cl-defstruct (etaf-sqlite--column
(:constructor etaf-sqlite--column-create))
@ -143,7 +156,12 @@ validated once and never comes from a runtime query."
(signal 'etaf-sqlite-unavailable nil)))
(defun etaf-sqlite--call (database function)
"Call FUNCTION with a short-lived connection for DATABASE."
"Call FUNCTION with a short-lived connection for DATABASE.
Keep a cleanup failure after a successful mutation conservative: the write may
already have committed even though closing the connection signalled. When the
body already failed, preserve that primary condition so transaction certainty
is determined by the transaction phase rather than by cleanup noise."
(etaf-sqlite--require-available)
(let* ((file (etaf-sqlite--database-file database))
(directory (file-name-directory file))
@ -151,20 +169,67 @@ validated once and never comes from a runtime query."
(when directory
(make-directory directory t))
(setq connection (sqlite-open file))
(unwind-protect
(funcall function connection)
(when (sqlitep connection)
(sqlite-close connection)))))
(let (result body-condition close-condition)
;; Keep the connection cleanup on all non-local exits, including a
;; caller's `throw'. Error and quit conditions are captured so cleanup
;; failures can be classified without masking the primary condition.
(unwind-protect
(condition-case err
(setq result (funcall function connection))
((error quit)
(setq body-condition err)))
(when (sqlitep connection)
(condition-case err
(sqlite-close connection)
((error quit)
(setq close-condition err))))
)
(when (and close-condition
etaf-sqlite--mutation-active
(memq etaf-sqlite--mutation-phase '(committed committing))
(not (and body-condition
(eq (car body-condition)
'etaf-sqlite-commit-unknown))))
(setq body-condition
(list 'etaf-sqlite-commit-unknown
(list :failure-phase 'close :error close-condition))))
(when (and close-condition (not body-condition))
(setq body-condition close-condition))
(if body-condition
(signal (car body-condition) (cdr body-condition))
result))))
(defun etaf-sqlite--transaction (connection function)
"Run FUNCTION in a transaction on CONNECTION and return its value."
(sqlite-transaction connection)
(condition-case err
(prog1 (funcall function connection)
(sqlite-commit connection))
(error
(sqlite-rollback connection)
(signal (car err) (cdr err)))))
(let ((value
(condition-case body-err
(funcall function connection)
((error quit)
(setq etaf-sqlite--mutation-phase 'rolling-back)
(let ((rollback-err
(condition-case rollback-condition
(progn (sqlite-rollback connection) nil)
((error quit) rollback-condition))))
(if rollback-err
(progn
(setq etaf-sqlite--mutation-phase 'unknown)
(signal 'etaf-sqlite-commit-unknown
(list :failure-phase 'rollback
:error body-err
:rollback-error rollback-err)))
(setq etaf-sqlite--mutation-phase 'rolled-back)
(signal (car body-err) (cdr body-err))))))))
(condition-case commit-err
(progn
(setq etaf-sqlite--mutation-phase 'committing)
(sqlite-commit connection)
(setq etaf-sqlite--mutation-phase 'committed)
value)
((error quit)
(setq etaf-sqlite--mutation-phase 'unknown)
(signal 'etaf-sqlite-commit-unknown
(list :failure-phase 'commit :error commit-err))))))
(defun etaf-sqlite--sql-type (type)
"Return SQLite DDL type for TYPE."
@ -319,9 +384,11 @@ exposing the inserted identity and affected row count to application code."
(unless primary
(signal 'etaf-sqlite-schema-error
(list "Mutations require a primary key")))
(etaf-sqlite--call
database
(lambda (connection)
(let ((etaf-sqlite--mutation-active t)
(etaf-sqlite--mutation-phase 'body))
(etaf-sqlite--call
database
(lambda (connection)
(etaf-sqlite--transaction
connection
(lambda (transaction-connection)
@ -405,27 +472,67 @@ exposing the inserted identity and affected row count to application code."
(list (format "Unsupported SQLite mutation: %S"
operation)))))))
(etaf-sqlite--mutation-result
transaction-connection operation value))))))))
transaction-connection operation value)))))))))
(defun etaf-sqlite--mutate-v2 (database operation payload)
"Return a commit-certified outcome for DATABASE mutation OPERATION and PAYLOAD.
This optional capability deliberately does not replace the v1 `:mutate'
callback. Successful transactions return `:certainty committed' and a
reconciliation token. A body failure is `:certainty rolled-back' only when
rollback succeeds; commit, rollback, or close uncertainty is returned as
`:certainty external-unknown' with the original condition in `:error'."
(let ((token (etaf-sqlite--reconciliation-token)))
(condition-case err
(let ((result (etaf-sqlite--mutate database operation payload)))
(list :certainty 'committed
:external-commit-certainty 'committed
:operation operation
:result result
:mutation-result result
:reconciliation-token token))
((error quit)
(list :certainty (if (eq (car err) 'etaf-sqlite-commit-unknown)
'external-unknown
'rolled-back)
:external-commit-certainty
(if (eq (car err) 'etaf-sqlite-commit-unknown)
'external-unknown
'rolled-back)
:operation operation
:error err
:reconciliation-token token)))))
;;;###autoload
(defun etaf-sqlite-source (database)
(cl-defun etaf-sqlite-source (database &key mutate-v2)
"Return an `etaf-data-source' backed by DATABASE.
The source accepts equality plist/alist queries and supports `insert',
`replace', `update', and `delete' mutations. Mutation results include the
operation, affected row count, and inserted identity when applicable. Every
operation uses a short connection; controllers remain the owner of reactive
state and lifecycle."
state and lifecycle. The legacy `:mutate' capability is exposed by default.
When MUTATE-V2 is non-nil, add the opt-in `:mutate-v2' capability that returns
explicit commit/rollback certainty and an opaque reconciliation token."
(unless (etaf-sqlite--database-p database)
(signal 'wrong-type-argument
(list 'etaf-sqlite-database-p database)))
(etaf-data-source
:name 'etaf-sqlite
:provider 'sqlite
:load (lambda (query page page-size)
(etaf-sqlite--select-items database query page page-size))
:mutate (lambda (operation payload)
(etaf-sqlite--mutate database operation payload))))
(let ((capabilities
(list :name 'etaf-sqlite
:provider 'sqlite
:load (lambda (query page page-size)
(etaf-sqlite--select-items database query page page-size))
:mutate (lambda (operation payload)
(etaf-sqlite--mutate database operation payload)))))
;; Keep the typed wrapper opt-in. The M4a additive path must not silently
;; change existing SQLite controllers from the v1 callback contract.
(when mutate-v2
(setq capabilities
(append capabilities
(list :mutate-v2
(lambda (operation payload)
(etaf-sqlite--mutate-v2 database operation payload))))))
(apply #'etaf-data-source capabilities)))
(provide 'etaf-sqlite)

View File

@ -88,8 +88,120 @@
(should (eq 'sqlite (plist-get source :provider)))
(should (functionp (plist-get source :load)))
(should (functionp (plist-get source :mutate)))
(should-not (plist-member source :mutate-v2))
(should (functionp
(plist-get (etaf-sqlite-source database :mutate-v2 t)
:mutate-v2)))
(should-not (plist-member source :dispose)))))
(ert-deftest etaf-sqlite-v2-mutation-outcome-reports-commit ()
"The optional v2 capability reports a committed transaction and token."
(etaf-sqlite-test--with-database (database)
(let* ((source (etaf-sqlite-source database :mutate-v2 t))
(mutate-v2 (plist-get source :mutate-v2))
(outcome (funcall mutate-v2 'insert
'(:id 3 :name "Alan" :score 30))))
(should (eq (plist-get outcome :certainty) 'committed))
(should (plist-get outcome :reconciliation-token))
(should (equal (plist-get (plist-get outcome :result) :operation)
'insert))
(should (= (plist-get (plist-get outcome :result) :id) 3)))))
(ert-deftest etaf-sqlite-v2-mutation-outcome-reports-rollback ()
"A failed v2 mutation reports rollback and preserves the original error."
(etaf-sqlite-test--with-database (database)
(let* ((source (etaf-sqlite-source database :mutate-v2 t))
(mutate-v2 (plist-get source :mutate-v2))
(outcome (funcall mutate-v2 'insert
'(:id 1 :name "Duplicate" :score 99)))
(loaded (funcall (plist-get source :load) nil 1 20)))
(should (eq (plist-get outcome :certainty) 'rolled-back))
(should (consp (plist-get outcome :error)))
(should (plist-get outcome :reconciliation-token))
(should (= (plist-get loaded :total) 2)))))
(ert-deftest etaf-sqlite-committed-mutation-load-failure-is-read-only-retry ()
"A committed SQLite row survives a failed projection read and retries safely."
(etaf-sqlite-test--with-database (database)
(let* ((base (etaf-sqlite-source database :mutate-v2 t))
(base-load (plist-get base :load))
(base-mutate-v2 (plist-get base :mutate-v2))
(load-count 0)
(mutate-count 0)
(fail-load t)
(source (copy-sequence base)))
(setf (plist-get source :load)
(lambda (&rest args)
(cl-incf load-count)
(if fail-load
(error "injected reconciliation read failure")
(apply base-load args))))
(setf (plist-get source :mutate-v2)
(lambda (operation payload)
(cl-incf mutate-count)
(funcall base-mutate-v2 operation payload)))
(let* ((initial (funcall base-load nil 1 20))
(controller (etaf-data-controller source :initial-result initial))
captured)
(unwind-protect
(progn
(condition-case condition
(etaf-data-mutate
controller 'insert '(:id 3 :name "Alan" :score 30))
(error (setq captured condition)))
(should captured)
(should (etaf-data-condition-projection-info captured))
(should (= mutate-count 1))
(should (= load-count 1))
(should (equal '(1 2)
(mapcar (lambda (row) (plist-get row :id))
(etaf-value (etaf-data-items controller)))))
(should (= 3
(plist-get (funcall base-load nil 1 20) :total)))
(setq fail-load nil)
(etaf-data-retry-reconciliation controller)
(should (= mutate-count 1))
(should (= load-count 2))
(should (eq 'success (etaf-value (etaf-data-status controller))))
(should (equal '(1 2 3)
(mapcar (lambda (row) (plist-get row :id))
(etaf-value (etaf-data-items controller)))))
(should (eq 'projected
(etaf-data-reconciliation-state controller))))
(etaf-data-stop controller))))))
(ert-deftest etaf-sqlite-v2-post-commit-signal-reports-unknown ()
"A signal after sqlite-commit must not claim the committed write rolled back."
(etaf-sqlite-test--with-database (database)
(let* ((source (etaf-sqlite-source database :mutate-v2 t))
(mutate-v2 (plist-get source :mutate-v2))
(original-commit (symbol-function 'sqlite-commit))
(outcome
(cl-letf (((symbol-function 'sqlite-commit)
(lambda (connection)
(funcall original-commit connection)
(error "post-commit fault"))))
(funcall mutate-v2 'insert
'(:id 3 :name "Committed" :score 30))))
(loaded (funcall (plist-get source :load) nil 1 20)))
(should (eq (plist-get outcome :certainty) 'external-unknown))
(should (= (plist-get loaded :total) 3)))))
(ert-deftest etaf-sqlite-v2-close-after-commit-reports-unknown ()
"A close signal after commit is also external uncertainty."
(etaf-sqlite-test--with-database (database)
(let* ((source (etaf-sqlite-source database :mutate-v2 t))
(mutate-v2 (plist-get source :mutate-v2))
(original-close (symbol-function 'sqlite-close))
(outcome
(cl-letf (((symbol-function 'sqlite-close)
(lambda (connection)
(funcall original-close connection)
(error "post-commit close fault"))))
(funcall mutate-v2 'insert
'(:id 3 :name "Committed" :score 30)))))
(should (eq (plist-get outcome :certainty) 'external-unknown)))))
(provide 'etaf-sqlite-tests)
;;; etaf-sqlite-tests.el ends here