540 lines
23 KiB
EmacsLisp
540 lines
23 KiB
EmacsLisp
;;; etaf-sqlite.el --- SQLite Data source for ETAF -*- lexical-binding: t; -*-
|
|
|
|
;; SPDX-License-Identifier: GPL-3.0-or-later
|
|
;; Author: ETAF contributors
|
|
;; Version: 0.1.0
|
|
;; Package-Requires: ((emacs "29.1") (etaf "0.1.0"))
|
|
;; URL: https://github.com/ginqi7/etaf-sqlite
|
|
|
|
;;; Commentary:
|
|
|
|
;; This package adapts Emacs' built-in SQLite API to the small capability
|
|
;; contract owned by `etaf-data'. It is intentionally a source adapter, not a
|
|
;; second Data controller and not a general ORM. Other storage systems can
|
|
;; implement the same `etaf-data-source' callbacks without changing ETAF.
|
|
|
|
;;; Code:
|
|
|
|
(require 'cl-lib)
|
|
(require 'subr-x)
|
|
(require 'sqlite)
|
|
(require 'etaf-data)
|
|
|
|
(define-error 'etaf-sqlite-error "Invalid ETAF SQLite operation")
|
|
(define-error 'etaf-sqlite-schema-error "Invalid ETAF SQLite schema"
|
|
'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))
|
|
id sql-name type nullable primary)
|
|
|
|
(cl-defstruct (etaf-sqlite--table
|
|
(:constructor etaf-sqlite--table-create))
|
|
id columns primary-key index)
|
|
|
|
(cl-defstruct (etaf-sqlite--database
|
|
(:constructor etaf-sqlite--database-create))
|
|
file table)
|
|
|
|
(defconst etaf-sqlite--types
|
|
'(integer real text blob any)
|
|
"Supported SQLite declaration types.")
|
|
|
|
(defun etaf-sqlite--identifier-p (value)
|
|
"Return non-nil when VALUE is a safe unquoted SQL identifier."
|
|
(and (stringp value)
|
|
(string-match-p "\\`[A-Za-z_][A-Za-z0-9_]*\\'" value)))
|
|
|
|
(defun etaf-sqlite--identifier (value kind)
|
|
"Return quoted SQL identifier VALUE for KIND or signal an error."
|
|
(unless (etaf-sqlite--identifier-p value)
|
|
(signal 'etaf-sqlite-schema-error
|
|
(list (format "Unsafe SQLite %s identifier: %S" kind value))))
|
|
(format "\"%s\"" value))
|
|
|
|
(defun etaf-sqlite--physical-name (value kind)
|
|
"Return the SQL name for semantic VALUE of KIND."
|
|
(etaf-sqlite--identifier
|
|
(cond
|
|
((stringp value) value)
|
|
((symbolp value) (symbol-name value))
|
|
(t (signal 'etaf-sqlite-schema-error
|
|
(list (format "SQLite %s must be a symbol or string: %S"
|
|
kind value)))))
|
|
kind))
|
|
|
|
(defun etaf-sqlite--scalar-id-p (value)
|
|
"Return non-nil when VALUE can identify a schema field."
|
|
(and value (or (symbolp value) (stringp value) (integerp value))))
|
|
|
|
;;;###autoload
|
|
(cl-defun etaf-sqlite-column
|
|
(id sql-name &key (type 'any) nullable primary)
|
|
"Declare a SQLite column with semantic ID and physical SQL-NAME.
|
|
|
|
TYPE is one of `integer', `real', `text', `blob', or `any'. NULLABLE allows
|
|
SQL NULL values. PRIMARY marks the table identity column. SQL-NAME is
|
|
validated once and never comes from a runtime query."
|
|
(unless (etaf-sqlite--scalar-id-p id)
|
|
(signal 'etaf-sqlite-schema-error
|
|
(list (format "Column ID must be a stable scalar: %S" id))))
|
|
(unless (etaf-sqlite--identifier-p sql-name)
|
|
(signal 'etaf-sqlite-schema-error
|
|
(list (format "Invalid SQLite column name: %S" sql-name))))
|
|
(unless (memq type etaf-sqlite--types)
|
|
(signal 'etaf-sqlite-schema-error
|
|
(list (format "Unsupported SQLite column type: %S" type))))
|
|
(etaf-sqlite--column-create
|
|
:id id :sql-name sql-name :type type
|
|
:nullable (and nullable t) :primary (and primary t)))
|
|
|
|
(defun etaf-sqlite--column-index (columns)
|
|
"Return an equal-tested index for COLUMNS."
|
|
(unless (and (proper-list-p columns) columns)
|
|
(signal 'etaf-sqlite-schema-error
|
|
(list "SQLite table requires at least one column")))
|
|
(let ((index (make-hash-table :test #'equal))
|
|
(names (make-hash-table :test #'equal)))
|
|
(dolist (column columns index)
|
|
(unless (etaf-sqlite--column-p column)
|
|
(signal 'etaf-sqlite-schema-error (list "Invalid SQLite column")))
|
|
(when (gethash (etaf-sqlite--column-id column) index)
|
|
(signal 'etaf-sqlite-schema-error
|
|
(list "Duplicate SQLite semantic column ID")))
|
|
(let ((name (downcase (etaf-sqlite--column-sql-name column))))
|
|
(when (gethash name names)
|
|
(signal 'etaf-sqlite-schema-error
|
|
(list "Duplicate SQLite physical column name")))
|
|
(puthash name t names))
|
|
(puthash (etaf-sqlite--column-id column) column index))))
|
|
|
|
;;;###autoload
|
|
(defun etaf-sqlite-table (id columns &optional primary-key)
|
|
"Declare table ID from COLUMNS and optional PRIMARY-KEY semantic ID."
|
|
(unless (or (symbolp id) (stringp id))
|
|
(signal 'etaf-sqlite-schema-error
|
|
(list (format "Table ID must be a stable scalar: %S" id))))
|
|
(let* ((columns (copy-sequence columns))
|
|
(index (etaf-sqlite--column-index columns))
|
|
(primary (and primary-key (gethash primary-key index))))
|
|
(when (and primary-key (not primary))
|
|
(signal 'etaf-sqlite-schema-error
|
|
(list (format "Unknown primary column: %S" primary-key))))
|
|
(when primary
|
|
(setf (etaf-sqlite--column-primary primary) t
|
|
(etaf-sqlite--column-nullable primary) nil))
|
|
(etaf-sqlite--table-create :id id :columns columns
|
|
:primary-key primary-key :index index)))
|
|
|
|
;;;###autoload
|
|
(defun etaf-sqlite-database (file table)
|
|
"Describe SQLite FILE using typed TABLE metadata."
|
|
(unless (and (stringp file) (not (string-empty-p file)))
|
|
(signal 'etaf-sqlite-schema-error (list "SQLite file must be nonempty")))
|
|
(unless (etaf-sqlite--table-p table)
|
|
(signal 'etaf-sqlite-schema-error (list "SQLite database needs a table")))
|
|
(etaf-sqlite--database-create :file (expand-file-name file) :table table))
|
|
|
|
(defun etaf-sqlite--require-available ()
|
|
"Signal when the running Emacs has no SQLite support."
|
|
(unless (sqlite-available-p)
|
|
(signal 'etaf-sqlite-unavailable nil)))
|
|
|
|
(defun etaf-sqlite--call (database function)
|
|
"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))
|
|
connection)
|
|
(when directory
|
|
(make-directory directory t))
|
|
(setq connection (sqlite-open file))
|
|
(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)
|
|
(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."
|
|
(pcase type
|
|
('integer "INTEGER")
|
|
('real "REAL")
|
|
('text "TEXT")
|
|
('blob "BLOB")
|
|
(_ "BLOB")))
|
|
|
|
(defun etaf-sqlite--column-ddl (column)
|
|
"Return DDL for COLUMN."
|
|
(concat (etaf-sqlite--identifier
|
|
(etaf-sqlite--column-sql-name column) "column")
|
|
" " (etaf-sqlite--sql-type
|
|
(etaf-sqlite--column-type column))
|
|
(when (etaf-sqlite--column-primary column) " PRIMARY KEY")
|
|
(unless (or (etaf-sqlite--column-nullable column)
|
|
(etaf-sqlite--column-primary column))
|
|
" NOT NULL")))
|
|
|
|
;;;###autoload
|
|
(defun etaf-sqlite-initialize (database)
|
|
"Create DATABASE's table if it does not exist and return DATABASE."
|
|
(etaf-sqlite--call
|
|
database
|
|
(lambda (connection)
|
|
(sqlite-execute
|
|
connection
|
|
(format "CREATE TABLE IF NOT EXISTS %s (%s)"
|
|
(etaf-sqlite--physical-name
|
|
(etaf-sqlite--table-id (etaf-sqlite--database-table database))
|
|
"table")
|
|
(mapconcat #'etaf-sqlite--column-ddl
|
|
(etaf-sqlite--table-columns
|
|
(etaf-sqlite--database-table database)) ", ")))))
|
|
database)
|
|
|
|
(defun etaf-sqlite--record-value (record key)
|
|
"Return KEY from plist, alist, or hash RECORD."
|
|
(cond
|
|
((hash-table-p record) (gethash key record))
|
|
((and (proper-list-p record) (keywordp (car record)))
|
|
(plist-get record key))
|
|
((listp record) (alist-get key record))
|
|
(t nil)))
|
|
|
|
(defun etaf-sqlite--query-pairs (table query)
|
|
"Return allowlisted (COLUMN . VALUE) pairs from QUERY using TABLE."
|
|
(cond
|
|
((null query) nil)
|
|
((and (proper-list-p query) (keywordp (car query)))
|
|
(cl-loop for (key value) on query by #'cddr
|
|
collect (cons (gethash key (etaf-sqlite--table-index table)) value)))
|
|
((listp query)
|
|
(cl-loop for (key . value) in query
|
|
collect (cons (gethash key (etaf-sqlite--table-index table)) value)))
|
|
(t
|
|
(signal 'etaf-sqlite-error
|
|
(list "SQLite query must be a plist or alist")))))
|
|
|
|
(defun etaf-sqlite--where (table query)
|
|
"Return (SQL . VALUES) for allowlisted equality QUERY using TABLE."
|
|
(let (parts values)
|
|
(dolist (pair (etaf-sqlite--query-pairs table query))
|
|
(unless (car pair)
|
|
(signal 'etaf-sqlite-error (list "Unknown SQLite query column")))
|
|
(push (format "%s = ?"
|
|
(etaf-sqlite--identifier
|
|
(etaf-sqlite--column-sql-name (car pair)) "column")
|
|
) parts)
|
|
(push (cdr pair) values))
|
|
(cons (if parts (concat " WHERE " (string-join (nreverse parts) " AND ")) "")
|
|
(nreverse values))))
|
|
|
|
(defun etaf-sqlite--row-plist (table row)
|
|
"Convert positional SQLite ROW to a semantic plist using TABLE."
|
|
(let (result)
|
|
(cl-loop for column in (etaf-sqlite--table-columns table)
|
|
for value in row
|
|
do (setq result
|
|
(append result
|
|
(list (etaf-sqlite--column-id column) value))))
|
|
result))
|
|
|
|
(defun etaf-sqlite--select-items (database query page page-size)
|
|
"Load PAGE of PAGE-SIZE items from DATABASE for QUERY."
|
|
(let* ((table (etaf-sqlite--database-table database))
|
|
(where (etaf-sqlite--where table query))
|
|
(values (cdr where))
|
|
(page (max 1 (or page 1)))
|
|
(page-size (max 1 (or page-size 20)))
|
|
(offset (* (1- page) page-size))
|
|
(names (mapcar (lambda (column)
|
|
(etaf-sqlite--identifier
|
|
(etaf-sqlite--column-sql-name column) "column"))
|
|
(etaf-sqlite--table-columns table)))
|
|
(table-name (etaf-sqlite--physical-name
|
|
(etaf-sqlite--table-id table) "table"))
|
|
(order (when-let* ((primary (etaf-sqlite--table-primary-key table)))
|
|
(format " ORDER BY %s"
|
|
(etaf-sqlite--identifier
|
|
(etaf-sqlite--column-sql-name
|
|
(gethash primary (etaf-sqlite--table-index table)))
|
|
"column")))))
|
|
(etaf-sqlite--call
|
|
database
|
|
(lambda (connection)
|
|
(let ((total (sqlite-select
|
|
connection
|
|
(format "SELECT COUNT(*) FROM %s%s" table-name
|
|
(car where))
|
|
values)))
|
|
(let ((rows (sqlite-select
|
|
connection
|
|
(format "SELECT %s FROM %s%s%s LIMIT ? OFFSET ?"
|
|
(string-join names ", ") table-name (car where)
|
|
(or order ""))
|
|
(append values (list page-size offset)))))
|
|
(list :items (mapcar (lambda (row)
|
|
(etaf-sqlite--row-plist table row)) rows)
|
|
:total (caar total)
|
|
:page page :page-size page-size)))))))
|
|
|
|
(defun etaf-sqlite--payload-columns (table payload)
|
|
"Return (COLUMN . VALUE) pairs from writable PAYLOAD fields using TABLE."
|
|
(let (result)
|
|
(dolist (column (etaf-sqlite--table-columns table) (nreverse result))
|
|
(let ((key (etaf-sqlite--column-id column)))
|
|
(when (and (not (etaf-sqlite--column-primary column))
|
|
(or (plist-member payload key)
|
|
(and (listp payload) (assoc key payload))))
|
|
(push (cons column (etaf-sqlite--record-value payload key)) result))))))
|
|
|
|
(defun etaf-sqlite--mutation-result (connection operation value)
|
|
"Return a normalized mutation result for OPERATION and VALUE from CONNECTION.
|
|
|
|
The result keeps source-specific details behind the Data contract while
|
|
exposing the inserted identity and affected row count to application code."
|
|
(list :operation operation
|
|
:id (when (eq operation 'insert)
|
|
(caar (sqlite-select connection "SELECT last_insert_rowid()")))
|
|
:changes (caar (sqlite-select connection "SELECT changes()"))
|
|
:value value))
|
|
|
|
(defun etaf-sqlite--mutate (database operation payload)
|
|
"Apply one Data mutation OPERATION with PAYLOAD to DATABASE."
|
|
(let* ((table (etaf-sqlite--database-table database))
|
|
(table-name (etaf-sqlite--physical-name
|
|
(etaf-sqlite--table-id table) "table"))
|
|
(primary (etaf-sqlite--table-primary-key table)))
|
|
(unless primary
|
|
(signal 'etaf-sqlite-schema-error
|
|
(list "Mutations require a primary key")))
|
|
(let ((etaf-sqlite--mutation-active t)
|
|
(etaf-sqlite--mutation-phase 'body))
|
|
(etaf-sqlite--call
|
|
database
|
|
(lambda (connection)
|
|
(etaf-sqlite--transaction
|
|
connection
|
|
(lambda (transaction-connection)
|
|
(let ((value
|
|
(pcase operation
|
|
('insert
|
|
(let* ((columns
|
|
(cl-remove-if
|
|
(lambda (column)
|
|
(null
|
|
(or (plist-member payload
|
|
(etaf-sqlite--column-id column))
|
|
(and (listp payload)
|
|
(assoc (etaf-sqlite--column-id column)
|
|
payload)))))
|
|
(etaf-sqlite--table-columns table)))
|
|
(names
|
|
(mapcar
|
|
(lambda (column)
|
|
(etaf-sqlite--identifier
|
|
(etaf-sqlite--column-sql-name column) "column"))
|
|
columns))
|
|
(marks (make-list (length columns) "?")))
|
|
(unless columns
|
|
(signal 'etaf-sqlite-error
|
|
(list "Insert payload is empty")))
|
|
(sqlite-execute
|
|
transaction-connection
|
|
(format "INSERT INTO %s (%s) VALUES (%s)"
|
|
table-name (string-join names ", ")
|
|
(string-join marks ", "))
|
|
(mapcar
|
|
(lambda (column)
|
|
(etaf-sqlite--record-value
|
|
payload (etaf-sqlite--column-id column)))
|
|
columns))))
|
|
((or 'replace 'update)
|
|
(let* ((id (etaf-sqlite--record-value payload primary))
|
|
(columns (etaf-sqlite--payload-columns table payload)))
|
|
(unless id
|
|
(signal 'etaf-sqlite-error
|
|
(list "Update payload lacks primary key")))
|
|
(unless columns
|
|
(signal 'etaf-sqlite-error
|
|
(list "Update payload has no fields")))
|
|
(sqlite-execute
|
|
transaction-connection
|
|
(format "UPDATE %s SET %s WHERE %s = ?"
|
|
table-name
|
|
(mapconcat
|
|
(lambda (pair)
|
|
(format "%s = ?"
|
|
(etaf-sqlite--identifier
|
|
(etaf-sqlite--column-sql-name (car pair))
|
|
"column")))
|
|
columns ", ")
|
|
(etaf-sqlite--identifier
|
|
(etaf-sqlite--column-sql-name
|
|
(gethash primary (etaf-sqlite--table-index table)))
|
|
"column"))
|
|
(append (mapcar #'cdr columns) (list id)))))
|
|
('delete
|
|
(let ((id (if (and (listp payload)
|
|
(or (plist-member payload primary)
|
|
(assoc primary payload)))
|
|
(etaf-sqlite--record-value payload primary)
|
|
payload)))
|
|
(unless id
|
|
(signal 'etaf-sqlite-error
|
|
(list "Delete payload lacks primary key")))
|
|
(sqlite-execute
|
|
transaction-connection
|
|
(format "DELETE FROM %s WHERE %s = ?" table-name
|
|
(etaf-sqlite--identifier
|
|
(etaf-sqlite--column-sql-name
|
|
(gethash primary (etaf-sqlite--table-index table)))
|
|
"column"))
|
|
(list id))))
|
|
(_
|
|
(signal 'etaf-sqlite-error
|
|
(list (format "Unsupported SQLite mutation: %S"
|
|
operation)))))))
|
|
(etaf-sqlite--mutation-result
|
|
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
|
|
(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. 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)))
|
|
(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)
|
|
|
|
;;; etaf-sqlite.el ends here
|